diff --git a/crates/font-catalog/src/fontconfig.rs b/crates/font-catalog/src/fontconfig.rs index 401e5f31..872db51a 100644 --- a/crates/font-catalog/src/fontconfig.rs +++ b/crates/font-catalog/src/fontconfig.rs @@ -1,7 +1,7 @@ //! The font folders named by the fontconfig configuration, read with a small tag scanner (no //! fontconfig library, no XML crate): only ``, `` and `` matter here. -//! The work is bounded: include depth, number and size of the files read, and every file is read -//! at most once. +//! The work is bounded: include depth, number and size of the files read, the folder entries +//! listed for includes, and every file is read and every folder listed at most once. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -11,6 +11,8 @@ use crate::FontEnv; const MAX_INCLUDE_DEPTH: u32 = 8; const MAX_FILES: usize = 512; const MAX_FILE_BYTES: u64 = 1 << 20; +/// Folder entries listed for `` folders, all folders together. +const MAX_DIR_ENTRIES: usize = 16_384; /// The folders the fontconfig configuration for `env` names, in the order it names them. /// @@ -21,7 +23,7 @@ const MAX_FILE_BYTES: u64 = 1 << 20; /// those `/run/host/font-dirs.xml` maps, and those of the host's own configuration when the /// sandbox can see it (`/run/host/etc/fonts/fonts.conf`), under `/run/host`. pub fn fontconfig_dirs(env: &FontEnv) -> Vec { - let mut reader = Reader { env, out: Vec::new(), seen: HashSet::new(), host: None }; + let mut reader = Reader { env, out: Vec::new(), seen: HashSet::new(), dirs: HashSet::new(), entries_left: MAX_DIR_ENTRIES, host: None }; let root = match &env.fontconfig_file { Some(f) => Some(f.clone()), None => ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(|p| env.rooted(p)).into_iter().find(|p| p.is_file()), @@ -45,6 +47,10 @@ struct Reader<'a> { env: &'a FontEnv, out: Vec, seen: HashSet, + /// The `` folders already listed (canonical paths). + dirs: HashSet, + /// Folder entries that may still be listed ([`MAX_DIR_ENTRIES`]). + entries_left: usize, /// While reading the host's configuration from a sandbox: where the host's root is. host: Option, } @@ -53,14 +59,27 @@ impl Reader<'_> { /// Read a configuration file or, for a folder, its `NN-name.conf` files in name order. fn include(&mut self, path: &Path, depth: u32) { if path.is_dir() { + // Check the budgets before listing: a configuration that names the same folder many + // times, or many large folders, must not list them over and over. + if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || self.entries_left == 0 { + return; + } + let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !self.dirs.insert(key) { + return; + } let Ok(rd) = std::fs::read_dir(path) else { return }; + let mut listed = 0; let mut files: Vec = rd + .take(self.entries_left) + .inspect(|_| listed += 1) .flatten() .map(|e| e.path()) .filter(|p| { p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())) }) .collect(); + self.entries_left -= listed; files.sort(); for f in files { self.file(&f, depth); @@ -191,3 +210,28 @@ fn elements(src: &str) -> Vec> { fn decode_entities(s: &str) -> String { s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_folder_included_many_times_is_listed_once() { + let root = std::env::temp_dir().join(format!("craft-font-catalog-fc-dirs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let conf_d = root.join("conf.d"); + std::fs::create_dir_all(&conf_d).unwrap(); + for (i, dir) in ["/a", "/b", "/c"].iter().enumerate() { + std::fs::write(conf_d.join(format!("{i}0-x.conf")), format!("{dir}")).unwrap(); + } + let main = root.join("fonts.conf"); + std::fs::write(&main, format!("{}", "conf.d".repeat(5000))).unwrap(); + let env = FontEnv { root: root.clone(), ..Default::default() }; + let mut reader = Reader { env: &env, out: Vec::new(), seen: HashSet::new(), dirs: HashSet::new(), entries_left: MAX_DIR_ENTRIES, host: None }; + reader.file(&main, 0); + assert_eq!(reader.out, vec![root.join("a"), root.join("b"), root.join("c")]); + assert_eq!(reader.dirs.len(), 1); + assert_eq!(reader.entries_left, MAX_DIR_ENTRIES - 3); + let _ = std::fs::remove_dir_all(&root); + } +}