diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 9e49d1276..80e4b31cc 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -303,6 +303,8 @@ size_t ZSTD_rust_determineBlockSize(int mode, size_t blockSize, size_t remaining size_t ZSTD_rust_validateSequence(U32 offBase, U32 matchLength, U32 minMatch, size_t posInSrc, U32 windowLog, size_t dictSize, int useSequenceProducer); +size_t ZSTD_rust_loadCEntropy(ZSTD_compressedBlockState_t* bs, void* workspace, + const void* dict, size_t dictSize); size_t ZSTD_rust_transferSequencesWBlockDelim( SeqStore_t* seqStore, ZSTD_SequencePosition* seqPos, const ZSTD_Sequence* inSeqs, size_t inSeqsSize, @@ -3566,113 +3568,12 @@ ZSTD_loadDictionaryContent(ZSTD_MatchState_t* ms, } -/* Dictionaries that assign zero probability to symbols that show up causes problems - * when FSE encoding. Mark dictionaries with zero probability symbols as FSE_repeat_check - * and only dictionaries with 100% valid symbols can be assumed valid. - */ -static FSE_repeat ZSTD_dictNCountRepeat(short* normalizedCounter, unsigned dictMaxSymbolValue, unsigned maxSymbolValue) -{ - U32 s; - if (dictMaxSymbolValue < maxSymbolValue) { - return FSE_repeat_check; - } - for (s = 0; s <= maxSymbolValue; ++s) { - if (normalizedCounter[s] == 0) { - return FSE_repeat_check; - } - } - return FSE_repeat_valid; -} - +/* Dictionary entropy-header loading lives in Rust; C keeps the public internal + * symbol and the dictionary-content orchestration around it. */ size_t ZSTD_loadCEntropy(ZSTD_compressedBlockState_t* bs, void* workspace, const void* const dict, size_t dictSize) { - short offcodeNCount[MaxOff+1]; - unsigned offcodeMaxValue = MaxOff; - const BYTE* dictPtr = (const BYTE*)dict; /* skip magic num and dict ID */ - const BYTE* const dictEnd = dictPtr + dictSize; - dictPtr += 8; - bs->entropy.huf.repeatMode = HUF_repeat_check; - - { unsigned maxSymbolValue = 255; - unsigned hasZeroWeights = 1; - size_t const hufHeaderSize = HUF_readCTable((HUF_CElt*)bs->entropy.huf.CTable, &maxSymbolValue, dictPtr, - (size_t)(dictEnd-dictPtr), &hasZeroWeights); - - /* We only set the loaded table as valid if it contains all non-zero - * weights. Otherwise, we set it to check */ - if (!hasZeroWeights && maxSymbolValue == 255) - bs->entropy.huf.repeatMode = HUF_repeat_valid; - - RETURN_ERROR_IF(HUF_isError(hufHeaderSize), dictionary_corrupted, ""); - dictPtr += hufHeaderSize; - } - - { unsigned offcodeLog; - size_t const offcodeHeaderSize = FSE_readNCount(offcodeNCount, &offcodeMaxValue, &offcodeLog, dictPtr, (size_t)(dictEnd-dictPtr)); - RETURN_ERROR_IF(FSE_isError(offcodeHeaderSize), dictionary_corrupted, ""); - RETURN_ERROR_IF(offcodeLog > OffFSELog, dictionary_corrupted, ""); - /* fill all offset symbols to avoid garbage at end of table */ - RETURN_ERROR_IF(FSE_isError(FSE_buildCTable_wksp( - bs->entropy.fse.offcodeCTable, - offcodeNCount, MaxOff, offcodeLog, - workspace, HUF_WORKSPACE_SIZE)), - dictionary_corrupted, ""); - /* Defer checking offcodeMaxValue because we need to know the size of the dictionary content */ - dictPtr += offcodeHeaderSize; - } - - { short matchlengthNCount[MaxML+1]; - unsigned matchlengthMaxValue = MaxML, matchlengthLog; - size_t const matchlengthHeaderSize = FSE_readNCount(matchlengthNCount, &matchlengthMaxValue, &matchlengthLog, dictPtr, (size_t)(dictEnd-dictPtr)); - RETURN_ERROR_IF(FSE_isError(matchlengthHeaderSize), dictionary_corrupted, ""); - RETURN_ERROR_IF(matchlengthLog > MLFSELog, dictionary_corrupted, ""); - RETURN_ERROR_IF(FSE_isError(FSE_buildCTable_wksp( - bs->entropy.fse.matchlengthCTable, - matchlengthNCount, matchlengthMaxValue, matchlengthLog, - workspace, HUF_WORKSPACE_SIZE)), - dictionary_corrupted, ""); - bs->entropy.fse.matchlength_repeatMode = ZSTD_dictNCountRepeat(matchlengthNCount, matchlengthMaxValue, MaxML); - dictPtr += matchlengthHeaderSize; - } - - { short litlengthNCount[MaxLL+1]; - unsigned litlengthMaxValue = MaxLL, litlengthLog; - size_t const litlengthHeaderSize = FSE_readNCount(litlengthNCount, &litlengthMaxValue, &litlengthLog, dictPtr, (size_t)(dictEnd-dictPtr)); - RETURN_ERROR_IF(FSE_isError(litlengthHeaderSize), dictionary_corrupted, ""); - RETURN_ERROR_IF(litlengthLog > LLFSELog, dictionary_corrupted, ""); - RETURN_ERROR_IF(FSE_isError(FSE_buildCTable_wksp( - bs->entropy.fse.litlengthCTable, - litlengthNCount, litlengthMaxValue, litlengthLog, - workspace, HUF_WORKSPACE_SIZE)), - dictionary_corrupted, ""); - bs->entropy.fse.litlength_repeatMode = ZSTD_dictNCountRepeat(litlengthNCount, litlengthMaxValue, MaxLL); - dictPtr += litlengthHeaderSize; - } - - RETURN_ERROR_IF(dictPtr+12 > dictEnd, dictionary_corrupted, ""); - bs->rep[0] = MEM_readLE32(dictPtr+0); - bs->rep[1] = MEM_readLE32(dictPtr+4); - bs->rep[2] = MEM_readLE32(dictPtr+8); - dictPtr += 12; - - { size_t const dictContentSize = (size_t)(dictEnd - dictPtr); - U32 offcodeMax = MaxOff; - if (dictContentSize <= ((U32)-1) - 128 KB) { - U32 const maxOffset = (U32)dictContentSize + 128 KB; /* The maximum offset that must be supported */ - offcodeMax = ZSTD_highbit32(maxOffset); /* Calculate minimum offset code required to represent maxOffset */ - } - /* All offset values <= dictContentSize + 128 KB must be representable for a valid table */ - bs->entropy.fse.offcode_repeatMode = ZSTD_dictNCountRepeat(offcodeNCount, offcodeMaxValue, MIN(offcodeMax, MaxOff)); - - /* All repCodes must be <= dictContentSize and != 0 */ - { U32 u; - for (u=0; u<3; u++) { - RETURN_ERROR_IF(bs->rep[u] == 0, dictionary_corrupted, ""); - RETURN_ERROR_IF(bs->rep[u] > dictContentSize, dictionary_corrupted, ""); - } } } - - return (size_t)(dictPtr - (const BYTE*)dict); + return ZSTD_rust_loadCEntropy(bs, workspace, dict, dictSize); } /* Dictionary format : diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 28894462b..e899341ff 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -37,6 +37,8 @@ pub mod zstd_compress; #[cfg(feature = "compression")] pub mod zstd_compress_api; #[cfg(feature = "compression")] +pub mod zstd_compress_dictionary; +#[cfg(feature = "compression")] pub mod zstd_compress_frame; #[cfg(feature = "compression")] pub mod zstd_compress_literals; diff --git a/rust/src/zstd_compress_dictionary.rs b/rust/src/zstd_compress_dictionary.rs new file mode 100644 index 000000000..176ced20b --- /dev/null +++ b/rust/src/zstd_compress_dictionary.rs @@ -0,0 +1,478 @@ +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(clippy::too_many_arguments)] + +//! Dictionary entropy-header loading. +//! +//! This is the Rust leaf for `ZSTD_dictNCountRepeat()` and +//! `ZSTD_loadCEntropy()` in `lib/compress/zstd_compress.c`. C retains the +//! original symbol through a thin compatibility shim and keeps dictionary +//! content loading and compression-context ownership on its side. + +use crate::bits::ZSTD_highbit32; +use crate::common::{LL_FSE_LOG, MAX_LL, MAX_ML, MAX_OFF, ML_FSE_LOG, OFF_FSE_LOG}; +use crate::entropy_common::FSE_readNCount; +use crate::errors::{ERR_isError, ZstdErrorCode, ERROR}; +use crate::fse_compress::FSE_buildCTable_wksp; +use crate::huf_compress::HUF_readCTable; +use crate::zstd_compress_stats::ZSTD_compressedBlockState_t; +use std::ffi::c_void; +use std::os::raw::{c_int, c_short, c_uint}; +use std::slice; + +const HUF_REPEAT_CHECK: c_int = 1; +const HUF_REPEAT_VALID: c_int = 2; +const FSE_REPEAT_CHECK: c_int = 1; +const FSE_REPEAT_VALID: c_int = 2; +const HUF_WORKSPACE_SIZE: usize = (8 << 10) + 512; +const DICTIONARY_ID_AND_MAGIC_SIZE: usize = 8; +const REPCODE_SECTION_SIZE: usize = 12; + +#[inline] +fn dictionary_corrupted() -> usize { + ERROR(ZstdErrorCode::DictionaryCorrupted) +} + +/// Matches C's `ZSTD_dictNCountRepeat()`. +#[inline] +fn dict_n_count_repeat( + normalized_counter: &[c_short], + dict_max_symbol_value: usize, + max_symbol_value: usize, +) -> c_int { + if dict_max_symbol_value < max_symbol_value { + return FSE_REPEAT_CHECK; + } + if normalized_counter.len() <= max_symbol_value + || normalized_counter[..=max_symbol_value].contains(&0) + { + return FSE_REPEAT_CHECK; + } + FSE_REPEAT_VALID +} + +/// Reads and builds one dictionary FSE table, advancing `offset` on success. +/// +/// C supplies `max_symbol_value` from `FSE_readNCount()` to the table builder +/// for the match-length and literal-length tables, but deliberately builds the +/// offset table with `MaxOff` so the unused tail is initialized. The caller +/// passes that distinction through `table_max_symbol_value`. +unsafe fn read_and_build_fse_table( + dictionary: &[u8], + offset: &mut usize, + normalized_counter: &mut [c_short], + initial_max_symbol_value: c_uint, + max_table_log: c_uint, + table: *mut c_uint, + table_max_symbol_value: c_uint, + workspace: *mut c_void, +) -> Result { + let input = &dictionary[*offset..]; + let mut max_symbol_value = initial_max_symbol_value; + let mut table_log = 0u32; + let header_size = unsafe { + FSE_readNCount( + normalized_counter.as_mut_ptr(), + &mut max_symbol_value, + &mut table_log, + input.as_ptr().cast(), + input.len(), + ) + }; + if ERR_isError(header_size) + || header_size > input.len() + || max_symbol_value as usize >= normalized_counter.len() + || table_log > max_table_log + { + return Err(()); + } + + let build_result = unsafe { + FSE_buildCTable_wksp( + table, + normalized_counter.as_ptr(), + table_max_symbol_value, + table_log, + workspace, + HUF_WORKSPACE_SIZE, + ) + }; + if ERR_isError(build_result) { + return Err(()); + } + + *offset = offset.checked_add(header_size).ok_or(())?; + Ok(max_symbol_value) +} + +/// Rust implementation of C `ZSTD_loadCEntropy()`. +/// +/// The input is assumed to have the dictionary magic and ID in its first eight +/// bytes, matching the C function's documented precondition. Malformed or +/// truncated entropy headers are converted to `ZSTD_error_dictionaryCorrupted` +/// just as the C `RETURN_ERROR_IF(..., dictionary_corrupted, ...)` paths do. +#[no_mangle] +pub unsafe extern "C" fn ZSTD_rust_loadCEntropy( + bs: *mut ZSTD_compressedBlockState_t, + workspace: *mut c_void, + dict: *const c_void, + dict_size: usize, +) -> usize { + if bs.is_null() + || dict.is_null() + || workspace.is_null() + || dict_size < DICTIONARY_ID_AND_MAGIC_SIZE + { + return dictionary_corrupted(); + } + + let dictionary = unsafe { slice::from_raw_parts(dict.cast::(), dict_size) }; + let mut offset = DICTIONARY_ID_AND_MAGIC_SIZE; + + unsafe { + (*bs).entropy.huf.repeatMode = HUF_REPEAT_CHECK; + } + + let mut huf_max_symbol_value = 255u32; + let mut huf_has_zero_weights = 1u32; + let huf_header_size = unsafe { + HUF_readCTable( + (*bs).entropy.huf.CTable.as_mut_ptr(), + &mut huf_max_symbol_value, + dictionary[offset..].as_ptr().cast(), + dictionary.len() - offset, + &mut huf_has_zero_weights, + ) + }; + if ERR_isError(huf_header_size) || huf_header_size > dictionary.len().saturating_sub(offset) { + return dictionary_corrupted(); + } + if huf_has_zero_weights == 0 && huf_max_symbol_value == 255 { + unsafe { + (*bs).entropy.huf.repeatMode = HUF_REPEAT_VALID; + } + } + offset = match offset.checked_add(huf_header_size) { + Some(value) => value, + None => return dictionary_corrupted(), + }; + + let mut offcode_ncount = [0i16; MAX_OFF + 1]; + let offcode_max_symbol_value = MAX_OFF as c_uint; + let offcode_header_max = match unsafe { + read_and_build_fse_table( + dictionary, + &mut offset, + &mut offcode_ncount, + offcode_max_symbol_value, + OFF_FSE_LOG as c_uint, + (*bs).entropy.fse.offcodeCTable.as_mut_ptr(), + MAX_OFF as c_uint, + workspace, + ) + } { + Ok(value) => value, + Err(()) => return dictionary_corrupted(), + }; + + let mut matchlength_ncount = [0i16; MAX_ML + 1]; + let matchlength_max_symbol_value = MAX_ML as c_uint; + let matchlength_header_max = match unsafe { + read_and_build_fse_table( + dictionary, + &mut offset, + &mut matchlength_ncount, + matchlength_max_symbol_value, + ML_FSE_LOG as c_uint, + (*bs).entropy.fse.matchlengthCTable.as_mut_ptr(), + matchlength_max_symbol_value, + workspace, + ) + } { + Ok(value) => value, + Err(()) => return dictionary_corrupted(), + }; + unsafe { + (*bs).entropy.fse.matchlength_repeatMode = + dict_n_count_repeat(&matchlength_ncount, matchlength_header_max as usize, MAX_ML); + } + + let mut litlength_ncount = [0i16; MAX_LL + 1]; + let litlength_max_symbol_value = MAX_LL as c_uint; + let litlength_header_max = match unsafe { + read_and_build_fse_table( + dictionary, + &mut offset, + &mut litlength_ncount, + litlength_max_symbol_value, + LL_FSE_LOG as c_uint, + (*bs).entropy.fse.litlengthCTable.as_mut_ptr(), + litlength_max_symbol_value, + workspace, + ) + } { + Ok(value) => value, + Err(()) => return dictionary_corrupted(), + }; + unsafe { + (*bs).entropy.fse.litlength_repeatMode = + dict_n_count_repeat(&litlength_ncount, litlength_header_max as usize, MAX_LL); + } + + if dictionary.len().saturating_sub(offset) < REPCODE_SECTION_SIZE { + return dictionary_corrupted(); + } + unsafe { + (*bs).rep[0] = u32::from_le_bytes(dictionary[offset..offset + 4].try_into().unwrap()); + (*bs).rep[1] = u32::from_le_bytes(dictionary[offset + 4..offset + 8].try_into().unwrap()); + (*bs).rep[2] = u32::from_le_bytes(dictionary[offset + 8..offset + 12].try_into().unwrap()); + } + offset += REPCODE_SECTION_SIZE; + + let dict_content_size = dictionary.len() - offset; + let offcode_max = if dict_content_size <= (u32::MAX as usize) - (128 << 10) { + let max_offset = (dict_content_size + (128 << 10)) as u32; + ZSTD_highbit32(max_offset) as usize + } else { + MAX_OFF + }; + let required_offcode_max = offcode_max.min(MAX_OFF); + let offcode_repeat_mode = dict_n_count_repeat( + &offcode_ncount, + offcode_header_max as usize, + required_offcode_max, + ); + unsafe { + (*bs).entropy.fse.offcode_repeatMode = offcode_repeat_mode; + } + + unsafe { + for &rep in &(*bs).rep { + if rep == 0 || rep as usize > dict_content_size { + return dictionary_corrupted(); + } + } + } + + offset +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::ERR_getErrorCode; + use crate::fse_compress::{FSE_normalizeCount, FSE_writeNCount}; + use crate::huf_compress::{HUF_buildCTable_wksp, HUF_writeCTable_wksp}; + use crate::mem::MEM_writeLE32; + use std::mem::{size_of, MaybeUninit}; + + const DICT_CONTENT_SIZE: usize = 16; + + #[derive(Clone, Copy)] + enum ZeroWeight { + None, + Huffman, + MatchLength, + LiteralLength, + Offset, + } + + fn assert_dictionary_corrupted(result: usize) { + assert!(ERR_isError(result)); + assert_eq!( + ERR_getErrorCode(result), + ZstdErrorCode::DictionaryCorrupted as i32 + ); + } + + unsafe fn write_fse_header( + dictionary: &mut [u8], + offset: &mut usize, + max_symbol_value: usize, + table_log: u32, + zero_weight: bool, + ) { + let mut counts = vec![1u32; max_symbol_value + 1]; + if zero_weight { + counts[0] = 0; + } + let total = counts.iter().map(|&count| count as usize).sum(); + let mut normalized = vec![0i16; max_symbol_value + 1]; + let normalized_log = FSE_normalizeCount( + normalized.as_mut_ptr(), + table_log, + counts.as_ptr(), + total, + max_symbol_value as c_uint, + 1, + ); + assert!(!ERR_isError(normalized_log)); + let header_size = FSE_writeNCount( + dictionary.as_mut_ptr().add(*offset).cast(), + dictionary.len() - *offset, + normalized.as_ptr(), + max_symbol_value as c_uint, + normalized_log as c_uint, + ); + assert!(!ERR_isError(header_size)); + *offset += header_size; + } + + fn make_dictionary(zero_weight: ZeroWeight) -> Vec { + let mut dictionary = vec![0u8; 2048]; + unsafe { + MEM_writeLE32(dictionary.as_mut_ptr().cast(), 0xEC30_A437); + MEM_writeLE32(dictionary.as_mut_ptr().add(4).cast(), 1234); + } + let mut offset = DICTIONARY_ID_AND_MAGIC_SIZE; + + let mut huf_counts = std::array::from_fn::<_, 256, _>(|symbol| (symbol + 1) as u32); + if matches!(zero_weight, ZeroWeight::Huffman) { + huf_counts[0] = 0; + } + let mut huf_table = [0usize; 257]; + let mut workspace = [0u32; HUF_WORKSPACE_SIZE / size_of::()]; + let huff_log = unsafe { + HUF_buildCTable_wksp( + huf_table.as_mut_ptr(), + huf_counts.as_ptr(), + 255, + 11, + workspace.as_mut_ptr().cast(), + size_of::<[u32; HUF_WORKSPACE_SIZE / size_of::()]>(), + ) + }; + assert!(!ERR_isError(huff_log)); + let huf_header_size = unsafe { + HUF_writeCTable_wksp( + dictionary.as_mut_ptr().add(offset).cast(), + dictionary.len() - offset, + huf_table.as_ptr(), + 255, + huff_log as c_uint, + workspace.as_mut_ptr().cast(), + size_of::<[u32; HUF_WORKSPACE_SIZE / size_of::()]>(), + ) + }; + assert!(!ERR_isError(huf_header_size)); + offset += huf_header_size; + + unsafe { + write_fse_header( + &mut dictionary, + &mut offset, + MAX_OFF, + OFF_FSE_LOG as u32, + matches!(zero_weight, ZeroWeight::Offset), + ); + write_fse_header( + &mut dictionary, + &mut offset, + MAX_ML, + ML_FSE_LOG as u32, + matches!(zero_weight, ZeroWeight::MatchLength), + ); + write_fse_header( + &mut dictionary, + &mut offset, + MAX_LL, + LL_FSE_LOG as u32, + matches!(zero_weight, ZeroWeight::LiteralLength), + ); + MEM_writeLE32(dictionary.as_mut_ptr().add(offset).cast(), 1); + MEM_writeLE32(dictionary.as_mut_ptr().add(offset + 4).cast(), 4); + MEM_writeLE32(dictionary.as_mut_ptr().add(offset + 8).cast(), 8); + } + offset += REPCODE_SECTION_SIZE; + dictionary[offset..offset + DICT_CONTENT_SIZE].fill(0xA5); + offset += DICT_CONTENT_SIZE; + dictionary.truncate(offset); + dictionary + } + + fn load(dictionary: &[u8]) -> (usize, ZSTD_compressedBlockState_t) { + let mut state = + unsafe { MaybeUninit::::zeroed().assume_init() }; + let mut workspace = [0u32; HUF_WORKSPACE_SIZE / size_of::()]; + let result = unsafe { + ZSTD_rust_loadCEntropy( + &mut state, + workspace.as_mut_ptr().cast(), + dictionary.as_ptr().cast(), + dictionary.len(), + ) + }; + (result, state) + } + + #[test] + fn valid_dictionary_loads_entropy_and_marks_repeat_tables_valid() { + let dictionary = make_dictionary(ZeroWeight::None); + let (header_size, state) = load(&dictionary); + assert_eq!(header_size, dictionary.len() - DICT_CONTENT_SIZE); + assert_eq!(state.rep, [1, 4, 8]); + assert_eq!(state.entropy.huf.repeatMode, HUF_REPEAT_VALID); + assert_eq!(state.entropy.fse.offcode_repeatMode, FSE_REPEAT_VALID); + assert_eq!(state.entropy.fse.matchlength_repeatMode, FSE_REPEAT_VALID); + assert_eq!(state.entropy.fse.litlength_repeatMode, FSE_REPEAT_VALID); + } + + #[test] + fn zero_weights_select_check_repeat_modes() { + for zero_weight in [ + ZeroWeight::Huffman, + ZeroWeight::Offset, + ZeroWeight::MatchLength, + ZeroWeight::LiteralLength, + ] { + let dictionary = make_dictionary(zero_weight); + let (header_size, state) = load(&dictionary); + assert_eq!(header_size, dictionary.len() - DICT_CONTENT_SIZE); + match zero_weight { + ZeroWeight::Huffman => { + assert_eq!(state.entropy.huf.repeatMode, HUF_REPEAT_CHECK) + } + ZeroWeight::Offset => { + assert_eq!(state.entropy.fse.offcode_repeatMode, FSE_REPEAT_CHECK) + } + ZeroWeight::MatchLength => { + assert_eq!(state.entropy.fse.matchlength_repeatMode, FSE_REPEAT_CHECK) + } + ZeroWeight::LiteralLength => { + assert_eq!(state.entropy.fse.litlength_repeatMode, FSE_REPEAT_CHECK) + } + ZeroWeight::None => unreachable!(), + } + } + } + + #[test] + fn truncated_entropy_headers_are_rejected() { + let dictionary = make_dictionary(ZeroWeight::None); + let (header_size, _) = load(&dictionary); + for truncated_size in [0, 7, 8, header_size - 1] { + let truncated = &dictionary[..truncated_size]; + let (result, _) = load(truncated); + assert_dictionary_corrupted(result); + } + } + + #[test] + fn corrupt_repcode_header_is_rejected() { + let mut dictionary = make_dictionary(ZeroWeight::None); + let (header_size, _) = load(&dictionary); + let repcode_offset = header_size - REPCODE_SECTION_SIZE; + dictionary[repcode_offset..repcode_offset + 4].fill(0); + let (result, _) = load(&dictionary); + assert_dictionary_corrupted(result); + } + + #[test] + fn dict_n_count_repeat_requires_coverage_and_nonzero_weights() { + let mut normalized = [1i16; 4]; + assert_eq!(dict_n_count_repeat(&normalized, 3, 3), FSE_REPEAT_VALID); + normalized[2] = 0; + assert_eq!(dict_n_count_repeat(&normalized, 3, 3), FSE_REPEAT_CHECK); + assert_eq!(dict_n_count_repeat(&normalized, 2, 2), FSE_REPEAT_CHECK); + } +}