mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 12:57:09 -06:00
#3661: bounded dictionary reads — hyphenation inflate limit, capped Hunspell and Turkish reads
Audit finding 4, and the rest of #3661. - dict.rs: `Dictionary::from_bytes` inflates with `decompress_to_vec_with_limit` (64 MiB; the bundled en-US one is ~1 MB) and caps the words it keeps (bytes and count): front coding lets a 2-byte line spell out a 255-byte word, so the inflated size alone doesn't bound storage. - hunspell.rs: one `read_capped` reader — regular files only, size checked on the open handle and the read itself bounded by `take(MAX_FILE + 1)`, so no TOCTOU between metadata and read and no endless read from a FIFO or device. - tr: the Turkish loader reads through `read_capped` and decodes with `hunspell::decode` (it read both files whole, with no cap at all). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45556583a5
commit
e4d2dafa9d
@@ -14,6 +14,11 @@ pub struct Dictionary {
|
||||
}
|
||||
|
||||
const MAGIC: &[u8] = b"DCHYPH1\n";
|
||||
/// Largest inflated dictionary, and most bytes of words kept from one (the bundled US English one
|
||||
/// is about 1 MB).
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
/// Most words read from one dictionary (the bundled one has about 160k).
|
||||
const MAX_ENTRIES: usize = 4_000_000;
|
||||
|
||||
impl Dictionary {
|
||||
/// Build from `(lowercase word, break mask)` pairs (sorted and de-duplicated here; the first
|
||||
@@ -76,9 +81,16 @@ impl Dictionary {
|
||||
miniz_oxide::deflate::compress_to_vec(&raw, 10)
|
||||
}
|
||||
|
||||
/// Deserialize data written by [`Dictionary::to_bytes`].
|
||||
/// Deserialize data written by [`Dictionary::to_bytes`]. Data that inflates past
|
||||
/// [`MAX_BYTES`], or holds more than [`MAX_ENTRIES`] words or [`MAX_BYTES`] of them, is refused.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Dictionary, String> {
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {:?}", e.status))?;
|
||||
Dictionary::from_bytes_within(data, MAX_BYTES, MAX_ENTRIES)
|
||||
}
|
||||
|
||||
/// [`Dictionary::from_bytes`] with the limits given.
|
||||
fn from_bytes_within(data: &[u8], max_bytes: usize, max_entries: usize) -> Result<Dictionary, String> {
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max_bytes)
|
||||
.map_err(|e| format!("hyphenation dictionary: inflate failed: {:?}", e.status))?;
|
||||
let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?;
|
||||
let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() };
|
||||
let mut cur: Vec<u8> = Vec::new();
|
||||
@@ -94,6 +106,11 @@ impl Dictionary {
|
||||
i = end + 1;
|
||||
let s = std::str::from_utf8(&cur).map_err(|_| "hyphenation dictionary: bad utf-8")?;
|
||||
let (w, m) = parse_hyphenated(s);
|
||||
// Front coding lets a short line repeat a long prefix: bound what is kept, not only
|
||||
// what is read.
|
||||
if d.masks.len() >= max_entries || d.words.len() + w.len() > max_bytes {
|
||||
return Err("hyphenation dictionary: too large".into());
|
||||
}
|
||||
d.words.push_str(&w);
|
||||
d.offs.push(d.words.len() as u32);
|
||||
d.masks.push(m);
|
||||
@@ -147,4 +164,31 @@ mod tests {
|
||||
assert_eq!(back.get("naïveté").map(|m| hyphenated("naïveté", m)), Some("naïve-té".to_string()));
|
||||
assert!(back.get("dog").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_data_is_refused() {
|
||||
const LIMIT: usize = 1 << 16;
|
||||
let within = |raw: &[u8]| Dictionary::from_bytes_within(&miniz_oxide::deflate::compress_to_vec(raw, 6), LIMIT, 1000);
|
||||
// Data that inflates past the limit is refused before anything is decoded.
|
||||
let mut raw = MAGIC.to_vec();
|
||||
raw.resize(LIMIT + 1, b'a');
|
||||
assert!(within(&raw).unwrap_err().contains("inflate failed"));
|
||||
// Short lines that each repeat a long prefix: small to inflate, but the words they spell
|
||||
// out would be ~100x larger. Kept storage is bounded too.
|
||||
let mut raw = MAGIC.to_vec();
|
||||
raw.push(0);
|
||||
raw.extend_from_slice(&[b'a'; 255]);
|
||||
raw.push(b'\n');
|
||||
for _ in 0..(LIMIT / 255 + 2) {
|
||||
raw.extend_from_slice(&[255, b'\n']);
|
||||
}
|
||||
assert!(raw.len() < LIMIT);
|
||||
assert!(within(&raw).unwrap_err().contains("too large"));
|
||||
// So is the number of words.
|
||||
let words: Vec<(String, u64)> = (0..1001).map(|i| (format!("w{i}"), 0)).collect();
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec(&Dictionary::from_entries(words).to_bytes()).unwrap();
|
||||
assert!(within(&raw).unwrap_err().contains("too large"));
|
||||
// The real limits leave the bundled dictionary whole.
|
||||
assert!(Dictionary::en_us().len() > 100_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,16 +140,31 @@ pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a dictionary file: a regular file of at most [`MAX_FILE`] bytes. The read itself is
|
||||
/// bounded, so a file that grows after its size is checked (or a filesystem that reports a wrong
|
||||
/// size) can't make it read more (#3661).
|
||||
pub(crate) fn read_capped(p: &Path) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
let err = |e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let f = std::fs::File::open(p).map_err(err)?;
|
||||
let meta = f.metadata().map_err(err)?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a regular file", p.display()));
|
||||
}
|
||||
if meta.len() > MAX_FILE {
|
||||
return Err(format!("{}: too large ({} bytes)", p.display(), meta.len()));
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
f.take(MAX_FILE + 1).read_to_end(&mut buf).map_err(err)?;
|
||||
if buf.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{}: too large", p.display()));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read a Hunspell pair from disk (size-capped), decoded per the `.aff`'s `SET` line.
|
||||
pub fn load(aff: &Path, dic: &Path) -> Result<AffixDict, String> {
|
||||
let read = |p: &Path| -> Result<Vec<u8>, String> {
|
||||
let len = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.len();
|
||||
if len > MAX_FILE {
|
||||
return Err(format!("{}: too large ({len} bytes)", p.display()));
|
||||
}
|
||||
std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))
|
||||
};
|
||||
let (aff_text, dic_text) = decode(&read(aff)?, &read(dic)?)?;
|
||||
let (aff_text, dic_text) = decode(&read_capped(aff)?, &read_capped(dic)?)?;
|
||||
let source = format!("Hunspell dictionary {}", dic.display());
|
||||
AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()])
|
||||
}
|
||||
@@ -283,4 +298,29 @@ mod tests {
|
||||
assert!(load(&dir.join("missing.aff"), &dic).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_are_bounded_and_regular_files_only() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-cap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let aff = dir.join("xx.aff");
|
||||
std::fs::write(&aff, "SET UTF-8\n").unwrap();
|
||||
// One byte over the cap (sparse: nothing is written) is refused without reading it.
|
||||
let big = dir.join("big.dic");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_FILE + 1).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
assert!(load(&aff, &big).unwrap_err().contains("too large"));
|
||||
// A directory, and on Unix an endless device, are not dictionary files.
|
||||
assert!(load(&aff, &dir).unwrap_err().contains("not a regular file"));
|
||||
#[cfg(unix)]
|
||||
assert!(load(&aff, Path::new("/dev/zero")).unwrap_err().contains("not a regular file"));
|
||||
assert!(t.elapsed().as_secs_f64() < 1.0, "{:?}", t.elapsed());
|
||||
// Turkish reads through the same reader.
|
||||
let (tr_aff, tr_dic) = (dir.join("tr_TR.aff"), dir.join("tr_TR.dic"));
|
||||
std::fs::write(&tr_aff, "SET UTF-8\nSFX 1 Y 1\nSFX 1 0 lar .\n").unwrap();
|
||||
std::fs::write(&tr_dic, "1\nkitap/1\n").unwrap();
|
||||
assert!(crate::tr::load(&tr_aff, &tr_dic).unwrap().contains("kitaplar"));
|
||||
assert!(crate::tr::load(&tr_aff, &big).unwrap_err().contains("too large"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-12
@@ -44,18 +44,17 @@ fn load_installed() -> TrDict {
|
||||
log::info!("Turkish spelling: no Hunspell tr_TR dictionary installed");
|
||||
return TrDict::default();
|
||||
};
|
||||
let read = |p: &std::path::Path| std::fs::read_to_string(p);
|
||||
match (read(&aff), read(&dic)) {
|
||||
(Ok(aff_text), Ok(dic_text)) => crate::hunspell::note_load(NAMES[0], &dic, TrDict::from_hunspell(&aff_text, &dic_text)).unwrap_or_else(|e| {
|
||||
log::warn!("Turkish spelling: {e}");
|
||||
TrDict::default()
|
||||
}),
|
||||
(a, d) => {
|
||||
let reason = a.err().or(d.err()).map_or_else(String::new, |e| e.to_string());
|
||||
let _ = crate::hunspell::note_load::<()>(NAMES[0], &dic, Err(reason));
|
||||
TrDict::default()
|
||||
}
|
||||
}
|
||||
crate::hunspell::note_load(NAMES[0], &dic, load(&aff, &dic)).unwrap_or_else(|e| {
|
||||
log::warn!("Turkish spelling: {e}");
|
||||
TrDict::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a Turkish Hunspell pair from disk: size-capped and decoded per the `.aff`'s `SET` line,
|
||||
/// like the other languages' dictionaries (#3661).
|
||||
pub(crate) fn load(aff: &std::path::Path, dic: &std::path::Path) -> Result<TrDict, String> {
|
||||
let (aff_text, dic_text) = crate::hunspell::decode(&crate::hunspell::read_capped(aff)?, &crate::hunspell::read_capped(dic)?)?;
|
||||
TrDict::from_hunspell(&aff_text, &dic_text)
|
||||
}
|
||||
|
||||
/// Use this Turkish dictionary from now on.
|
||||
|
||||
Reference in New Issue
Block a user