catalog: bound keyword list path expansion and index synonym merges (#3641)

* catalog: bound keyword list path expansion and index synonym merges

* address review: hold keyword list writer to the path budget the reader enforces

* address review: charge the emitted parent path in the keyword list writer
This commit is contained in:
Dylan M. Taylor
2026-11-03 22:51:11 -05:00
committed by GitHub
parent 9eb7b25e31
commit 00b020cf2f
2 changed files with 115 additions and 22 deletions
+60 -22
View File
@@ -87,6 +87,12 @@ pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec<String> {
/// The deepest a keyword list file may nest keywords: a file is input, so its nesting is bounded.
pub const MAX_LEVELS: usize = 64;
/// The most bytes of full keyword paths a keyword list file may spell out. Every line names its
/// keyword by its whole path (`parent|child`), so a file of one long keyword with many short ones
/// inside it would otherwise grow into far more memory than its own size (CWE-400). Lightroom
/// Classic's own lists of tens of thousands of keywords come to a few MiB.
pub const MAX_LIST_PATH_BYTES: usize = 64 << 20; // 64 MiB
/// A keyword list as written by [`Catalog::keyword_list_text`]: the file's text, the keywords in
/// it (paths), and what the format can't hold and so is left out (a keyword, with those inside it,
/// as its path; a synonym as `path {synonym}`).
@@ -114,6 +120,8 @@ pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
let mut synonyms: std::collections::HashMap<usize, std::collections::HashSet<String>> = std::collections::HashMap::new();
// the keyword at each level above the line: its path and where it is in `out`
let mut above: Vec<(String, usize)> = Vec::new();
// bytes of full paths spelled out so far, checked against `MAX_LIST_PATH_BYTES` before each
let mut path_bytes = 0usize;
for (i, line) in text.split('\n').enumerate() {
let n = i + 1;
if line.trim().is_empty() {
@@ -162,6 +170,11 @@ pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
return Err(bad(n, "“|” separates a keyword's levels: a name can't hold one"));
}
above.truncate(depth);
let parent_len = above.last().map_or(0, |(parent, _)| parent.len().saturating_add(1));
path_bytes = path_bytes.saturating_add(parent_len.saturating_add(name.len()));
if path_bytes > MAX_LIST_PATH_BYTES {
return Err(bad(n, &format!("the keywords' full paths come to more than {} MiB", MAX_LIST_PATH_BYTES >> 20)));
}
let path = match above.last() {
Some((parent, _)) => format!("{parent}{SEP}{name}"),
None => name.to_string(),
@@ -189,6 +202,14 @@ pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> {
from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count())
}
/// Add each of `more` to `synonyms` that isn't there in any case, in order. One lowered set of
/// what is there, not a search of the growing list per synonym: an imported list can give a
/// keyword many synonyms, and the search was quadratic in them (CWE-407).
fn merge_synonyms(synonyms: &mut Vec<String>, more: impl IntoIterator<Item = String>) {
let mut seen: std::collections::HashSet<String> = synonyms.iter().map(|s| s.to_lowercase()).collect();
synonyms.extend(more.into_iter().filter(|s| seen.insert(s.to_lowercase())));
}
/// Keep the first of case-insensitively equal keywords.
fn dedupe(v: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
@@ -402,11 +423,7 @@ impl Catalog {
Some(there) => {
there.info.new_keywords_inside |= l.info.new_keywords_inside;
there.info.keyword_shortcut |= l.info.keyword_shortcut;
for s in l.info.synonyms {
if !there.info.synonyms.iter().any(|x| same(x, &s)) {
there.info.synonyms.push(s);
}
}
merge_synonyms(&mut there.info.synonyms, l.info.synonyms);
}
None => {
list.insert(path.to_lowercase(), ListedKeyword { path, info: l.info });
@@ -467,8 +484,11 @@ impl Catalog {
let defaults = KeywordInfo::default();
let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults);
let mut out = ExportKeywords::default();
let push = |v: &mut Vec<String>, s: &str| {
if !s.is_empty() && !v.iter().any(|x| same(x, s)) {
// what each list holds, lowered: a search of the list per name was quadratic in a keyword's
// synonyms, which an imported list sets (CWE-407)
let (mut flat_seen, mut paths_seen) = (std::collections::HashSet::new(), std::collections::HashSet::new());
let push = |v: &mut Vec<String>, seen: &mut std::collections::HashSet<String>, s: &str| {
if !s.is_empty() && seen.insert(s.to_lowercase()) {
v.push(s.to_string());
}
};
@@ -481,11 +501,11 @@ impl Catalog {
let levels: Vec<&str> = path.split(SEP).collect();
let mut names = |at: usize, i: &KeywordInfo| {
if let Some(name) = levels.get(at) {
push(&mut out.flat, name);
push(&mut out.flat, &mut flat_seen, name);
}
if i.export_synonyms {
for s in &i.synonyms {
push(&mut out.flat, s.trim());
push(&mut out.flat, &mut flat_seen, s.trim());
}
}
};
@@ -499,7 +519,7 @@ impl Catalog {
}
}
}
push(&mut out.hierarchical, &path);
push(&mut out.hierarchical, &mut paths_seen, &path);
}
out
}
@@ -509,6 +529,13 @@ impl Catalog {
/// keyword, a keyword left out of export in square brackets; by name. What the format can't hold
/// is left out and named, so the file always reads back.
pub fn keyword_list_text(&self) -> KeywordListFile {
self.keyword_list_text_within(MAX_LIST_PATH_BYTES)
}
/// [`keyword_list_text`](Self::keyword_list_text) with the full paths written held to
/// `max_path_bytes` as [`parse_keyword_list`] holds them: a keyword past it is named
/// unwritable, with its subtree, so the file reads back.
fn keyword_list_text_within(&self, max_path_bytes: usize) -> KeywordListFile {
// a name the list would read as something else, or not at all
fn unwritable(name: &str) -> bool {
name.trim().is_empty()
@@ -519,14 +546,27 @@ impl Catalog {
|| (name.starts_with('[') && name.ends_with(']'))
|| (name.starts_with('{') && name.ends_with('}'))
}
fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, file: &mut KeywordListFile) {
fn write(
c: &Catalog,
nodes: &[KeywordNode],
depth: usize,
file: &mut KeywordListFile,
path_bytes: &mut usize,
max_path_bytes: usize,
parent_len: usize,
) {
let tabs = "\t".repeat(depth);
for n in nodes {
// the keywords inside go with it: under its parent they'd be other keywords
if depth >= MAX_LEVELS || unwritable(&n.name) {
// (a line's full path is what the reader counts against its path budget)
// the path the reader rebuilds from the lines written: the parent as emitted, a
// separator, the name (a child's `n.path` can spell its parent in another case)
let emitted = if depth == 0 { n.name.len() } else { parent_len + SEP.len_utf8() + n.name.len() };
if depth >= MAX_LEVELS || unwritable(&n.name) || path_bytes.saturating_add(emitted) > max_path_bytes {
file.unwritable.push(n.path.clone());
continue;
}
*path_bytes += emitted;
let info = c.keyword_info(&n.path).cloned().unwrap_or_default();
if info.include_on_export {
file.text.push_str(&format!("{tabs}{}\n", n.name));
@@ -536,19 +576,21 @@ impl Catalog {
file.written.push(n.path.clone());
// the reader trims synonyms and takes one per spelling: an empty one, or another's
// in a different case, is no loss; a line break or a “|” is
let mut synonyms: Vec<&str> = Vec::new();
// (one lowered set, not a search of those written so far per synonym: quadratic
// in a keyword's synonyms, which an imported list sets — CWE-407)
let mut synonyms: std::collections::HashSet<String> = std::collections::HashSet::new();
for s in info.synonyms.iter().map(|s| s.trim()) {
if s.is_empty() || synonyms.iter().any(|x| same(x, s)) {
if s.is_empty() || synonyms.contains(&s.to_lowercase()) {
continue;
}
if s.chars().any(char::is_control) || s.contains(SEP) {
file.unwritable.push(format!("{} {{{s}}}", n.path));
} else {
synonyms.push(s);
synonyms.insert(s.to_lowercase());
file.text.push_str(&format!("{tabs}\t{{{s}}}\n"));
}
}
write(c, &n.children, depth + 1, file);
write(c, &n.children, depth + 1, file, path_bytes, max_path_bytes, emitted);
}
}
let mut file = KeywordListFile::default();
@@ -568,7 +610,7 @@ impl Catalog {
}
}
}
write(self, &self.keyword_tree(), 0, &mut file);
write(self, &self.keyword_tree(), 0, &mut file, &mut 0, max_path_bytes, 0);
file.unwritable.extend(over_deep);
file
}
@@ -632,11 +674,7 @@ impl Catalog {
// one the library has: the list's synonyms join its own (listed only if it gains some)
let mut info_now = list.get(&key).map(|l| l.info.clone()).unwrap_or_default();
let before = info_now.synonyms.len();
for s in &info.synonyms {
if !info_now.synonyms.iter().any(|x| same(x, s)) {
info_now.synonyms.push(s.clone());
}
}
merge_synonyms(&mut info_now.synonyms, info.synonyms.iter().cloned());
if info_now.synonyms.len() > before {
let path = list.get(&key).map_or(path, |l| l.path.clone());
list.insert(key, ListedKeyword { path, info: info_now });
+55
View File
@@ -556,6 +556,17 @@ fn the_keyword_list_is_written_as_tab_indented_text() {
assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
}
/// The path budget the reader enforces holds for the writer too: a keyword past it, and its
/// subtree, is named unwritable instead of making a file `keyword.import` rejects.
#[test]
fn the_keyword_list_written_stays_within_the_path_budget() {
let (c, _) = lib(&[&["Places|Lisbon", "beach"], &["Events|Weddings"]]);
let file = c.keyword_list_text_within(17);
assert_eq!(file.text, "beach\nEvents\nPlaces\n");
assert_eq!(file.unwritable, ["Events|Weddings", "Places|Lisbon"]);
assert_eq!(c.keyword_list_text().unwritable, Vec::<String>::new());
}
/// Reading a list gives each keyword's path and attributes: what was written comes back, and a
/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
#[test]
@@ -765,3 +776,47 @@ fn keyword_tree_caps_a_keyword_at_the_list_depth() {
let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0);
assert_eq!(deepest, MAX_LEVELS);
}
/// A keyword list file spells each keyword's whole path, so one long keyword with many short ones
/// inside it expands into far more than its own size: past `MAX_LIST_PATH_BYTES` it is refused
/// with the line where that happened, instead of growing without bound (CWE-400).
#[test]
fn a_list_whose_paths_expand_past_the_budget_is_refused() {
let parent = "p".repeat(1 << 20);
let children = MAX_LIST_PATH_BYTES / parent.len() + 2;
let mut text = format!("{parent}\n");
for i in 0..children {
text.push_str(&format!("\tc{i}\n"));
}
let err = parse_keyword_list(&text).unwrap_err().to_string();
assert!(err.contains("full paths come to more than"), "{err}");
// a list within the budget still reads
let ok = parse_keyword_list(&format!("{parent}\n\tchild\n")).unwrap();
assert_eq!(ok.len(), 2);
assert_eq!(ok[1].0, format!("{parent}|child"));
}
/// Many synonyms joining a keyword the library has, its export and its keyword list file each keep
/// every spelling once, in order, without a search of the growing list per synonym (CWE-407).
#[test]
fn many_synonyms_are_gathered_once_each_in_order() {
const N: usize = 40_000;
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["Trip"])) }).unwrap();
let mut incoming: Vec<String> = (0..N).map(|i| format!("syn{i}")).collect();
incoming.push("TRIP".into());
incoming.push("SYN7".into());
let started = std::time::Instant::now();
let (op, added, updated) = c.import_keywords_ops(&[("travel".into(), KeywordInfo { synonyms: incoming, ..KeywordInfo::default() })]);
c.apply(op).unwrap();
assert_eq!((added, updated), (0, 1));
let synonyms = c.keyword_info("travel").unwrap().synonyms.clone();
assert_eq!(synonyms.len(), N + 1);
assert_eq!(synonyms[0], "Trip");
assert_eq!(synonyms[8], "syn7");
let exported = c.export_keywords(&["travel".into()]);
assert_eq!(exported.flat.len(), N + 2);
let file = c.keyword_list_text();
assert_eq!(file.text.lines().count(), N + 2);
assert!(started.elapsed() < std::time::Duration::from_secs(20), "took {:?}", started.elapsed());
}