collective

Browse / github.com/storytold/craft-libs / patch

font-catalog: per-document font scopes, regular-file check, confined folders

font-catalog: per-document font scopes, regular-file check, confined folders Fonts added for one document now stay in that document. Before this, every added face won over installed fonts for the whole process, so one document's embedded subset replaced the same family in every other open document (wordcraft#3687, deckcraft#1206). Part of moving the apps onto `craft-font-catalog` (#125). ## Document scopes - `new_scope()` returns a `Scope`. `add_bytes_in` / `add_file_in` add faces to it, and `remove_scope` drops them. - Scoped lookups: `resolve_in`, `by_postscript_name_in`, `family_in`, `styles_in`, `families_in`, `load_family_in`. - Inside a scope, lookup order is: t

Status
open
Score
0.00
Adopters
0 other operators
Author
aiongg (imported from pull request #376, open)
Base
9ff719499d8f4730b93071abed993e5f12fe1be8
Size
+579 −67 in 7 files
Review
pending
Shared
2026-10-28
Id
d168800e4cd3e6ada50a5bc6

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/font-catalog/Cargo.toml crates/font-catalog/README.md crates/font-catalog/src/catalog.rs crates/font-catalog/src/lib.rs crates/font-catalog/src/scan.rs crates/font-catalog/src/tests.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -784,6 +784,7 @@ name = "craft-font-catalog" version = "0.1.0" dependencies = [  "fontique",+ "libc",  "skrifa 0.47.0",  "thiserror 2.0.21",  "windows-registry",diff --git a/crates/font-catalog/Cargo.toml b/crates/font-catalog/Cargo.toml--- a/crates/font-catalog/Cargo.toml+++ b/crates/font-catalog/Cargo.toml@@ -21,6 +21,9 @@ thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true } +[target.'cfg(unix)'.dependencies]+libc = "0.2"+ [target.'cfg(windows)'.dependencies] windows-registry = "0.6" diff --git a/crates/font-catalog/README.md b/crates/font-catalog/README.md--- a/crates/font-catalog/README.md+++ b/crates/font-catalog/README.md@@ -11,6 +11,7 @@ A `Catalog` reads, in this order: | The platform's font folders (`font_dirs`) | `System` | all | | The suite's font store (`font_store_dir`) | `Store` | all | | The app's own folders (`Catalog::set_app_dirs`, for example `office_font_dirs`) | `App` | all |+| Folders the user or an automation caller picked (`Catalog::set_confined_dirs`) | `User` | all | | Files the platform's font service knows (`system` feature, through fontique) | `System` | macOS, Windows, Linux, FreeBSD | | The registry's font list | `System` | Windows | | Files from injected listers (`Catalog::with_lister`) | `System` | all |@@ -44,7 +45,7 @@ The font service covers fonts outside those folders:  ## Scanning -The scan reads each file's table directory and its `name`, `OS/2` and `fvar` tables, at most 1 MiB per table, never the whole file. Faces without outline tables (`glyf`, `CFF `, `CFF2`, `VARC`) are left out. Files in folders count when they have a font extension (`.ttf`, `.otf`, `.ttc`, `.otc`); files a font service or lister names count whatever their name. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic.+The scan reads each file's table directory and its `name`, `OS/2` and `fvar` tables, at most 1 MiB per table, never the whole file. Faces without outline tables (`glyf`, `CFF `, `CFF2`, `VARC`) are left out. Files in folders count when they have a font extension (`.ttf`, `.otf`, `.ttc`, `.otc`); files a font service or lister names count whatever their name. Only regular files are read (after following links), and on Unix the open doesn't wait, so a FIFO or device named `x.ttf` can't block the scan. The scan follows links, except in the folders given to `set_confined_dirs`: there a link whose target is outside the folder is skipped. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic.  Each face (`FaceInfo`) has its family and style, legacy family and style, family names in other languages, full name, PostScript name, weight, width, italic, a named instance's axis settings, `fsType`, the OS/2 code page bits, its file and index, and its source. Each named instance of a variable font is a face of its own. @@ -60,7 +61,17 @@ Each face (`FaceInfo`) has its family and style, legacy family and style, family - The first scan runs on first use, or on a background thread after `scan_in_background`. Lookups wait for it. - Lookups read an immutable snapshot, so a rescan never blocks them. - `load_family` reads a family's files once. Other threads asking for the same family wait and share the result; other families load at the same time. Faces of one file share its bytes (`Arc<[u8]>`).-- `add_bytes` and `add_file` add fonts from documents, downloads or the app's bundle, within `Limits`. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces, for example when a document closes.+- `add_bytes` and `add_file` add fonts from downloads, the user or the app's bundle, within `Limits`, for every lookup. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces. `add_file` refuses a path that isn't a regular file.++## Document scopes++A document's own fonts (embedded, or in a folder beside it) go into a scope, so they change only that document's lookups.++- `new_scope()` returns a `Scope`. `add_bytes_in(scope, …)` and `add_file_in(scope, …)` add faces to it.+- `resolve_in`, `by_postscript_name_in`, `family_in`, `styles_in`, `families_in` and `load_family_in` look up in a scope. Inside a scope, its own faces come first, then the faces added without a scope, then the installed faces. A family the scope has faces of keeps its other styles from the installed fonts.+- Other scopes and the unscoped lookups never see a scope's faces.+- `remove_scope(scope)` removes its faces when the document closes. Other scopes keep theirs, and `remove_added` doesn't touch scoped faces.+- `Limits::max_added_faces` counts the added faces of every scope together.  ## Changes @@ -68,7 +79,7 @@ Each face (`FaceInfo`) has its family and style, legacy family and style, family - `installed_fonts_changed` compares the modification times of every folder the scan read (and the folders of files a font service named) and, on Windows, the registry's font list. It costs a few hundred `stat` calls, not a scan. - `rescan_if_changed` does both; apps call it when their window is activated. - `watch(interval, on_change)` polls on a background thread.-- `generation` counts the catalog's changes.+- `generation` counts the changes unscoped lookups see: scans, and faces added or removed without a scope. A scope's changes leave it alone, so other documents don't lay out again. `generation_in(scope)` adds the scope's own changes and never repeats a value, even after `remove_scope`; a document lays out again when it changes.  ## One enumeration for the UI and documents diff --git a/crates/font-catalog/src/catalog.rs b/crates/font-catalog/src/catalog.rs--- a/crates/font-catalog/src/catalog.rs+++ b/crates/font-catalog/src/catalog.rs@@ -1,6 +1,6 @@ //! The catalog: every installed face, found by any of its names, loaded once. -use std::collections::{HashMap, HashSet};+use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak};@@ -50,15 +50,15 @@ pub enum FontFileError {     Gone, } -/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`]),-/// which may come from documents or downloads.+/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`] and+/// their scoped forms), which may come from documents or downloads. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Limits {     /// The largest file accepted.     pub max_file_bytes: u64,     /// The most faces read from one file (a collection).     pub max_faces_per_file: u32,-    /// The most added faces the catalog holds in all.+    /// The most added faces the catalog holds in all, every scope's included.     pub max_added_faces: usize, } @@ -77,6 +77,12 @@ pub enum Match {     NearestStyle, } +/// A set of added faces that only lookups in it see: one document's fonts+/// ([`Catalog::new_scope`]). Inside a scope, its own faces come first, then the faces added+/// without a scope, then the installed faces.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub struct Scope(u64);+ /// A face's data: the whole file (or a suitcase font's resource) and the face's index in it. #[derive(Clone, Debug)] pub struct FontData {@@ -106,6 +112,23 @@ enum Alias {     Face(usize), } +impl Alias {+    fn face(&self) -> usize {+        match self {+            Alias::Family(i) | Alias::Legacy(i, _) | Alias::Face(i) => *i,+        }+    }++    /// The order [`Index::resolve`] tries kinds of alias in.+    fn tier(&self) -> u8 {+        match self {+            Alias::Family(_) => 0,+            Alias::Legacy(..) => 1,+            Alias::Face(_) => 2,+        }+    }+}+ /// One state of the catalog: what lookups read while a scan builds the next one. #[derive(Default)] struct Index {@@ -115,6 +138,8 @@ struct Index {     by_family: HashMap<String, Vec<usize>>,     aliases: HashMap<String, Vec<Alias>>,     postscript: HashMap<String, usize>,+    /// How many of `faces` (the first ones) came from the added faces.+    added: usize, }  impl Index {@@ -123,11 +148,15 @@ impl Index {     fn build(added: &[Face], scanned: &[Face]) -> Self {         let mut faces: Vec<Face> = Vec::new();         let mut seen = HashSet::new();-        for f in added.iter().chain(scanned) {+        let mut from_added = 0;+        for (n, f) in added.iter().chain(scanned).enumerate() {             let variations: Vec<(u32, u32)> = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect();             let key = (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations);             if seen.insert(key) {                 faces.push(f.clone());+                if n < added.len() {+                    from_added += 1;+                }             }         }         let mut by_family: HashMap<String, Vec<usize>> = HashMap::new();@@ -169,7 +198,7 @@ impl Index {         }         families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone()));         let infos: Arc<[FaceInfo]> = faces.iter().map(|f| f.info.clone()).collect();-        Self { faces, infos, families: families.into(), by_family, aliases, postscript }+        Self { faces, infos, families: families.into(), by_family, aliases, postscript, added: from_added }     }      fn face(&self, i: usize) -> Option<&Face> {@@ -246,9 +275,30 @@ fn style_key(style: &str) -> String {     if ns.is_empty() { "regular".into() } else { ns } } +fn family_name(index: &Index, name: &str) -> Option<String> {+    let i = *index.family_of(name)?.first()?;+    index.face(i).map(|f| f.info.family.clone())+}++/// The faces of the family `family` names; `None` when no family has that name.+fn styles_of(index: &Index, family: &str) -> Option<Vec<FaceInfo>> {+    Some(index.family_of(family)?.iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect())+}++fn resolve_face(index: &Index, name: &str, style: &str) -> Option<(FaceInfo, Match)> {+    let (i, matched) = index.resolve(name, style)?;+    Some((index.face(i)?.info.clone(), matched))+}++fn postscript_face(index: &Index, name: &str) -> Option<FaceInfo> {+    let i = *index.postscript.get(&norm(name))?;+    index.face(i).map(|f| f.info.clone())+}+ /// What the catalog reads besides the platform's font folders. struct Config {     app_dirs: Vec<PathBuf>,+    confined_dirs: Vec<PathBuf>,     listers: Vec<Lister>,     font_service: bool, }@@ -269,6 +319,7 @@ struct ScanState { #[derive(Default)] struct Stamp {     app_dirs: Vec<PathBuf>,+    confined_dirs: Vec<PathBuf>,     /// Every folder the scan listed, and the folders holding the files listers named, with their     /// modification times (`None`: missing).     dirs: Vec<(PathBuf, Option<SystemTime>)>,@@ -278,14 +329,115 @@ struct Stamp {  #[derive(Default)] struct Added {+    /// Faces added without a scope.     faces: Vec<Face>,+    scopes: HashMap<Scope, Scoped>,+    /// The bytes of faces added as bytes, with or without a scope.     blobs: HashMap<u64, Arc<[u8]>>,     next_blob: u64,+    /// Counts every scope's changes together, so a scope's stamp never repeats.+    scope_changes: u64,+}++impl Added {+    /// Record a change of `scope`'s faces: a new stamp, and a view to build again.+    fn changed(&mut self, scope: Scope) -> &mut Scoped {+        self.scope_changes += 1;+        let stamp = self.scope_changes;+        let scoped = self.scopes.entry(scope).or_default();+        scoped.stamp = stamp;+        scoped.view = None;+        scoped+    }++    /// Every added face, every scope's included: what [`Limits::max_added_faces`] bounds.+    fn count(&self) -> usize {+        self.faces.len() + self.scopes.values().map(|s| s.faces.len()).sum::<usize>()+    }++    /// Drop the bytes no added face uses any more.+    fn drop_unused_blobs(&mut self) {+        let used: HashSet<u64> = self.faces.iter().chain(self.scopes.values().flat_map(|s| &s.faces)).filter_map(|f| f.info.blob).collect();+        self.blobs.retain(|b, _| used.contains(b));+    }+}++/// A scope's faces, and the view its lookups read. A removed scope keeps its entry, with no+/// faces, so its stamp never goes back.+#[derive(Default)]+struct Scoped {+    faces: Vec<Face>,+    /// [`Added::scope_changes`] at the scope's last change, for [`Catalog::generation_in`].+    stamp: u64,+    /// Built on the first lookup after a change of the scope or of the shared index.+    view: Option<Arc<View>>,+}++/// What lookups in a scope read: an index of the scope's faces and the shared faces of their+/// families, consulted before the shared index. [`View::answers`] says which of the two a name+/// resolves in, so the result is what one index of every face would give.+struct View {+    /// The shared index this view was built from.+    base: Arc<Index>,+    index: Index,+    /// The shared families and the scope's, sorted ignoring case.+    families: Arc<[String]>,+}++impl View {+    fn build(own: &[Face], base: Arc<Index>) -> Self {+        let keys: BTreeSet<String> = own.iter().map(|f| f.info.family.to_ascii_lowercase()).collect();+        let shared: Vec<Face> = keys.iter().filter_map(|k| base.by_family.get(k)).flatten().filter_map(|i| base.face(*i)).cloned().collect();+        let index = Index::build(own, &shared);+        let mut families: Vec<String> = base.families.to_vec();+        let before = families.len();+        families.extend(index.families.iter().filter(|f| !base.by_family.contains_key(&f.to_ascii_lowercase())).cloned());+        if families.len() > before {+            families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone()));+        }+        Self { base, index, families: families.into() }+    }++    /// Whether `name` resolves to the scope's faces, trying names in [`Index::resolve`]'s order:+    /// an exact family name, then each kind of alias. A family the view holds has scope faces;+    /// otherwise the first kind of alias either index has decides, and the view's aliases list+    /// the scope's faces first.+    fn answers(&self, name: &str) -> bool {+        let lower = name.to_ascii_lowercase();+        if self.index.by_family.contains_key(&lower) {+            return true;+        }+        if self.base.by_family.contains_key(&lower) {+            return false;+        }+        let key = norm(name);+        let (own, shared) = (self.index.aliases.get(&key), self.base.aliases.get(&key));+        for tier in 0..3 {+            if let Some(a) = own.into_iter().flatten().find(|a| a.tier() == tier) {+                return a.face() < self.index.added;+            }+            if shared.into_iter().flatten().any(|a| a.tier() == tier) {+                return false;+            }+        }+        false+    }++    /// The scope's face with PostScript name `name`.+    fn postscript(&self, name: &str) -> Option<usize> {+        self.index.postscript.get(&norm(name)).copied().filter(|i| *i < self.index.added)+    } }  /// A family's faces, loaded once ([`Catalog::load_family`]). type FamilyLoad = Arc<OnceLock<Arc<[LoadedFace]>>>; +/// Why a path isn't read as a font: it is a folder, a FIFO, a device or a socket, which could+/// block or never end.+fn not_a_regular_file() -> FontFileError {+    FontFileError::Io(scan::not_a_regular_file())+}+ /// The installed fonts: the platform's font folders, the files its font service knows, the font /// store and the app's own folders, plus faces added from files or bytes. ///@@ -302,7 +454,9 @@ pub struct Catalog {     stamp: Mutex<Stamp>,     generation: AtomicU64,     added: Mutex<Added>,-    loaded: Mutex<HashMap<String, FamilyLoad>>,+    next_scope: AtomicU64,+    /// Loaded families by scope (`None`: unscoped) and lowercase family name.+    loaded: Mutex<HashMap<(Option<Scope>, String), FamilyLoad>>,     files: Mutex<HashMap<PathBuf, Weak<[u8]>>>, } @@ -320,13 +474,19 @@ impl Catalog {     pub fn new(env: FontEnv) -> Self {         Self {             env,-            config: RwLock::new(Config { app_dirs: Vec::new(), listers: Vec::new(), font_service: cfg!(feature = "system") }),+            config: RwLock::new(Config {+                app_dirs: Vec::new(),+                confined_dirs: Vec::new(),+                listers: Vec::new(),+                font_service: cfg!(feature = "system"),+            }),             index: RwLock::new(Arc::new(Index::default())),             ready: OnceLock::new(),             scan: Mutex::new(ScanState::default()),             stamp: Mutex::new(Stamp::default()),             generation: AtomicU64::new(0),             added: Mutex::new(Added::default()),+            next_scope: AtomicU64::new(0),             loaded: Mutex::new(HashMap::new()),             files: Mutex::new(HashMap::new()),         }@@ -361,6 +521,13 @@ impl Catalog {         self.write_config().app_dirs = dirs;     } +    /// Folders the user or an automation caller picked, read with their subfolders from the next+    /// scan on, as [`Source::User`]. The scan stays inside each: a link whose target is outside+    /// the folder is skipped. A change shows in [`Catalog::installed_fonts_changed`].+    pub fn set_confined_dirs(&self, dirs: Vec<PathBuf>) {+        self.write_config().confined_dirs = dirs;+    }+     fn write_config(&self) -> std::sync::RwLockWriteGuard<'_, Config> {         self.config.write().unwrap_or_else(PoisonError::into_inner)     }@@ -432,8 +599,11 @@ impl Catalog {             return false;         }         let stamp = lock(&self.stamp);-        if stamp.app_dirs != self.read_config().app_dirs {-            return true;+        {+            let config = self.read_config();+            if stamp.app_dirs != config.app_dirs || stamp.confined_dirs != config.confined_dirs {+                return true;+            }         }         if stamp.dirs.iter().any(|(p, t)| modified(p) != *t) {             return true;@@ -462,11 +632,22 @@ impl Catalog {         Watch { stop }     } -    /// Counts the catalog's changes: each scan, and each face added or removed.+    /// Counts the changes unscoped lookups see: each scan, and each face added or removed+    /// without a scope. Faces added to or removed from a scope don't change it, so other+    /// documents don't lay out again; [`Catalog::generation_in`] counts those too.     pub fn generation(&self) -> u64 {         self.generation.load(Ordering::Acquire)     } +    /// Counts the changes lookups in `scope` see: [`Catalog::generation`] plus a stamp of the+    /// scope's last change, taken from one counter of every scope's changes. It never goes down+    /// and never repeats a value, [`Catalog::remove_scope`] and adding to the scope again+    /// included.+    pub fn generation_in(&self, scope: Scope) -> u64 {+        let own = lock(&self.added).scopes.get(&scope).map_or(0, |s| s.stamp);+        self.generation() + own+    }+     fn snapshot(&self) -> Arc<Index> {         self.ensure();         Arc::clone(&self.index.read().unwrap_or_else(PoisonError::into_inner))@@ -478,12 +659,35 @@ impl Catalog {         self.generation.fetch_add(1, Ordering::AcqRel);     } +    /// The shared index, and the view of `scope` when the scope has faces.+    fn lookup(&self, scope: Scope) -> (Arc<Index>, Option<Arc<View>>) {+        let base = self.snapshot();+        let mut added = lock(&self.added);+        let Some(scoped) = added.scopes.get_mut(&scope).filter(|s| !s.faces.is_empty()) else { return (base, None) };+        if let Some(view) = &scoped.view+            && Arc::ptr_eq(&view.base, &base)+        {+            return (base, Some(Arc::clone(view)));+        }+        let view = Arc::new(View::build(&scoped.faces, Arc::clone(&base)));+        scoped.view = Some(Arc::clone(&view));+        (base, Some(view))+    }+     /// The family names, sorted ignoring case.     pub fn families(&self) -> Arc<[String]> {         Arc::clone(&self.snapshot().families)     } -    /// Every face, added faces first.+    /// The family names in `scope` ([`Catalog::families`] plus the scope's), sorted ignoring case.+    pub fn families_in(&self, scope: Scope) -> Arc<[String]> {+        match self.lookup(scope) {+            (_, Some(view)) => Arc::clone(&view.families),+            (base, None) => Arc::clone(&base.families),+        }+    }++    /// Every face added without a scope or installed, added faces first.     pub fn faces(&self) -> Arc<[FaceInfo]> {         Arc::clone(&self.snapshot().infos)     }@@ -491,15 +695,25 @@ impl Catalog {     /// The family `name` names: its own name ignoring ASCII case, or any other of its names     /// (another language, a legacy name) ignoring case and everything but letters and digits.     pub fn family(&self, name: &str) -> Option<String> {-        let index = self.snapshot();-        let i = *index.family_of(name)?.first()?;-        index.face(i).map(|f| f.info.family.clone())+        family_name(&self.snapshot(), name)+    }++    /// [`Catalog::family`] in `scope`.+    pub fn family_in(&self, scope: Scope, name: &str) -> Option<String> {+        let (base, view) = self.lookup(scope);+        view.filter(|v| v.answers(name)).and_then(|v| family_name(&v.index, name)).or_else(|| family_name(&base, name))     }      /// The faces of the family `name` names ([`Catalog::family`]).     pub fn styles(&self, family: &str) -> Vec<FaceInfo> {-        let index = self.snapshot();-        index.family_of(family).unwrap_or_default().iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect()+        styles_of(&self.snapshot(), family).unwrap_or_default()+    }++    /// [`Catalog::styles`] in `scope`: for a family the scope has faces of, the scope's faces and+    /// the shared faces of other styles.+    pub fn styles_in(&self, scope: Scope, family: &str) -> Vec<FaceInfo> {+        let (base, view) = self.lookup(scope);+        view.filter(|v| v.answers(family)).and_then(|v| styles_of(&v.index, family)).or_else(|| styles_of(&base, family)).unwrap_or_default()     }      /// The face for `name` in `style`, and how it matched. `name` is a family name (any of its@@ -509,17 +723,29 @@ impl Catalog {     /// italic differing counting as 1000, then the nearest width. An empty style is `Regular`.     /// `None` when nothing has that name.     pub fn resolve(&self, name: &str, style: &str) -> Option<(FaceInfo, Match)> {-        let index = self.snapshot();-        let (i, matched) = index.resolve(name, style)?;-        Some((index.face(i)?.info.clone(), matched))+        resolve_face(&self.snapshot(), name, style)+    }++    /// [`Catalog::resolve`] in `scope`: a name the scope's faces answer to resolves among them+    /// and the shared faces of their families, any other name as [`Catalog::resolve`].+    pub fn resolve_in(&self, scope: Scope, name: &str, style: &str) -> Option<(FaceInfo, Match)> {+        let (base, view) = self.lookup(scope);+        view.filter(|v| v.answers(name)).and_then(|v| resolve_face(&v.index, name, style)).or_else(|| resolve_face(&base, name, style))     }      /// The face with this PostScript name (a named instance's too), ignoring case and everything     /// but letters and digits.     pub fn by_postscript_name(&self, name: &str) -> Option<FaceInfo> {-        let index = self.snapshot();-        let i = *index.postscript.get(&norm(name))?;-        index.face(i).map(|f| f.info.clone())+        postscript_face(&self.snapshot(), name)+    }++    /// [`Catalog::by_postscript_name`] in `scope`, the scope's faces first.+    pub fn by_postscript_name_in(&self, scope: Scope, name: &str) -> Option<FaceInfo> {+        let (base, view) = self.lookup(scope);+        match view.and_then(|v| Some((v.postscript(name)?, v))) {+            Some((i, v)) => v.index.face(i).map(|f| f.info.clone()),+            None => postscript_face(&base, name),+        }     }      /// The data of `face`, read from its file (shared with other faces of the file still loaded).@@ -540,11 +766,15 @@ impl Catalog {         if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) {             return Ok(bytes);         }-        let size = std::fs::metadata(path)?.len();+        use std::io::Read;+        let file = scan::open_regular(path, false)?;+        let size = file.metadata()?.len();         if size > MAX_LOAD {             return Err(FontFileError::TooLarge { size, max: MAX_LOAD });         }-        let bytes: Arc<[u8]> = std::fs::read(path)?.into();+        let mut data = Vec::new();+        file.take(MAX_LOAD).read_to_end(&mut data)?;+        let bytes: Arc<[u8]> = data.into();         let mut files = lock(&self.files);         files.retain(|_, w| w.strong_count() > 0);         files.insert(path.to_path_buf(), Arc::downgrade(&bytes));@@ -557,15 +787,42 @@ impl Catalog {     /// read are left out. After a rescan, the next call reads the files again.     pub fn load_family(&self, family: &str) -> Arc<[LoadedFace]> {         let Some(name) = self.family(family) else { return Arc::from([]) };-        let cell = Arc::clone(lock(&self.loaded).entry(name.to_ascii_lowercase()).or_default());+        let cell = Arc::clone(lock(&self.loaded).entry((None, name.to_ascii_lowercase())).or_default());         Arc::clone(             cell.get_or_init(|| self.styles(&name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()),         )     } +    /// [`Catalog::load_family`] in `scope`. A family the scope has no faces of is shared with+    /// unscoped loads.+    pub fn load_family_in(&self, scope: Scope, family: &str) -> Arc<[LoadedFace]> {+        let (_, view) = self.lookup(scope);+        let Some(name) = view.filter(|v| v.answers(family)).and_then(|v| family_name(&v.index, family)) else { return self.load_family(family) };+        let cell = Arc::clone(lock(&self.loaded).entry((Some(scope), name.to_ascii_lowercase())).or_default());+        Arc::clone(cell.get_or_init(|| {+            self.styles_in(scope, &name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()+        }))+    }++    /// A new, empty scope, for one document's fonts. Faces added to it with+    /// [`Catalog::add_bytes_in`] and [`Catalog::add_file_in`] are seen only by lookups in it+    /// ([`Catalog::resolve_in`] and the other `_in` lookups) until [`Catalog::remove_scope`].+    pub fn new_scope(&self) -> Scope {+        Scope(self.next_scope.fetch_add(1, Ordering::Relaxed))+    }+     /// Add the faces of a font file's `bytes` (a font or a collection) for this session, as-    /// `source`. Returns the faces added.+    /// `source`, seen by every lookup. Returns the faces added.     pub fn add_bytes(&self, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+        self.add_bytes_to(None, bytes, source, limits)+    }++    /// [`Catalog::add_bytes`] into `scope`: only lookups in the scope see the faces.+    pub fn add_bytes_in(&self, scope: Scope, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+        self.add_bytes_to(Some(scope), bytes, source, limits)+    }++    fn add_bytes_to(&self, scope: Option<Scope>, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {         let size = bytes.len() as u64;         if size > limits.max_file_bytes {             return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });@@ -576,7 +833,7 @@ impl Catalog {         }         self.ensure();         let mut added = lock(&self.added);-        if added.faces.len() + faces.len() > limits.max_added_faces {+        if added.count() + faces.len() > limits.max_added_faces {             return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });         }         let blob = added.next_blob;@@ -585,13 +842,27 @@ impl Catalog {         for f in &mut faces {             f.info.blob = Some(blob);         }-        Ok(self.push_added(added, faces))+        Ok(self.push_added(added, scope, faces))     } -    /// Add the faces of the font file at `path` for this session, as `source`, read in place.-    /// Returns the faces added.+    /// Add the faces of the font file at `path` for this session, as `source`, read in place and+    /// seen by every lookup. Returns the faces added. A path that isn't a regular file (after+    /// following links) is refused unopened.     pub fn add_file(&self, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {-        let size = std::fs::metadata(path)?.len();+        self.add_file_to(None, path, source, limits)+    }++    /// [`Catalog::add_file`] into `scope`: only lookups in the scope see the faces.+    pub fn add_file_in(&self, scope: Scope, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+        self.add_file_to(Some(scope), path, source, limits)+    }++    fn add_file_to(&self, scope: Option<Scope>, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+        let meta = std::fs::metadata(path)?;+        if !meta.is_file() {+            return Err(not_a_regular_file());+        }+        let size = meta.len();         if size > limits.max_file_bytes {             return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });         }@@ -606,27 +877,37 @@ impl Catalog {         }         self.ensure();         let added = lock(&self.added);-        if added.faces.len() + faces.len() > limits.max_added_faces {+        if added.count() + faces.len() > limits.max_added_faces {             return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });         }-        Ok(self.push_added(added, faces))+        Ok(self.push_added(added, scope, faces))     } -    fn push_added(&self, mut added: MutexGuard<'_, Added>, faces: Vec<Face>) -> Vec<FaceInfo> {+    /// Unscoped faces join the shared index (a new generation); a scope's faces only its view.+    fn push_added(&self, mut added: MutexGuard<'_, Added>, scope: Option<Scope>, faces: Vec<Face>) -> Vec<FaceInfo> {         let infos = faces.iter().map(|f| f.info.clone()).collect();-        added.faces.extend(faces);-        let scanned = self.scanned();-        self.publish(Index::build(&added.faces, &scanned));+        match scope {+            None => {+                added.faces.extend(faces);+                let scanned = self.scanned();+                self.publish(Index::build(&added.faces, &scanned));+            }+            Some(scope) => {+                added.changed(scope).faces.extend(faces);+                // Under the lock, as `publish` does: no load reads the old faces after the stamp.+                lock(&self.loaded).retain(|(s, _), _| *s != Some(scope));+            }+        }         infos     } -    /// Remove every added face of `source` (a closed document's fonts). Returns how many.+    /// Remove every face of `source` added without a scope (a closed document's fonts). Returns+    /// how many. Scopes keep theirs ([`Catalog::remove_scope`]).     pub fn remove_added(&self, source: Source) -> usize {         let mut added = lock(&self.added);         let before = added.faces.len();         added.faces.retain(|f| f.info.source != source);-        let blobs: HashSet<u64> = added.faces.iter().filter_map(|f| f.info.blob).collect();-        added.blobs.retain(|b, _| blobs.contains(b));+        added.drop_unused_blobs();         let removed = before - added.faces.len();         if removed > 0 {             let scanned = self.scanned();@@ -635,6 +916,21 @@ impl Catalog {         removed     } +    /// Remove `scope`'s faces (its document closed). Returns how many. Other scopes and unscoped+    /// lookups don't change, nor does [`Catalog::generation`]. The scope keeps a few bytes, so+    /// [`Catalog::generation_in`] never repeats a value.+    pub fn remove_scope(&self, scope: Scope) -> usize {+        let mut added = lock(&self.added);+        let removed = added.scopes.get(&scope).map_or(0, |s| s.faces.len());+        if removed == 0 {+            return 0;+        }+        added.changed(scope).faces.clear();+        added.drop_unused_blobs();+        lock(&self.loaded).retain(|(s, _), _| *s != Some(scope));+        removed+    }+     /// The faces the last scan found.     fn scanned(&self) -> Vec<Face> {         let scan = lock(&self.scan);@@ -645,14 +941,16 @@ impl Catalog {      fn scan_now(&self, refresh: bool) {         let mut state = lock(&self.scan);-        let (app_dirs, listers, service) = {+        let (app_dirs, confined_dirs, listers, service) = {             let c = self.read_config();-            (c.app_dirs.clone(), c.listers.clone(), c.font_service)+            (c.app_dirs.clone(), c.confined_dirs.clone(), c.listers.clone(), c.font_service)         };         let ignore_case = self.env.os == Os::Windows;-        let mut roots: Vec<(PathBuf, Source)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System)).collect();-        roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store)));-        roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App)));+        // Each folder with its source and whether the walk stays inside it.+        let mut roots: Vec<(PathBuf, Source, bool)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System, false)).collect();+        roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store, false)));+        roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App, false)));+        roots.extend(confined_dirs.iter().map(|d| (d.clone(), Source::User, true)));          let mut named: Vec<PathBuf> = Vec::new();         let mut registered = Vec::new();@@ -668,13 +966,14 @@ impl Catalog {         }          let mut walk = Walk { ignore_case, seen_dirs: HashSet::new(), seen_files: HashSet::new(), entries: 0, dirs: Vec::new(), files: Vec::new() };-        for (root, source) in &roots {-            walk.dir(root, *source, 0);+        for (root, source, confined) in &roots {+            let inside = confined.then(|| walk.key(root));+            walk.dir(root, *source, 0, inside.as_deref());         }         let mut parents = HashSet::new();         for file in named {             if file.is_dir() {-                walk.dir(&file, Source::System, 0);+                walk.dir(&file, Source::System, 0, None);                 continue;             }             if let Some(parent) = file.parent()@@ -682,17 +981,23 @@ impl Catalog {             {                 walk.dirs.push((parent.to_path_buf(), modified(parent)));             }-            walk.file(file, Source::System, true);+            walk.file(file, Source::System, true, None);         }          let mut files = HashMap::new();-        for (path, source, by_name) in walk.files {+        for (path, source, by_name, real) in walk.files {             let Ok(meta) = std::fs::metadata(&path) else { continue };+            // A FIFO or device named like a font would block the scan or never end.+            if !meta.is_file() {+                continue;+            }             let (len, mtime) = (meta.len(), meta.modified().ok());             let cached = state.files.remove(&path).filter(|c| c.len == len && c.modified == mtime);             let mut faces = match cached {                 Some(c) => c.faces,-                None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => scan::file_faces(&path, MAX_SCAN_READ, source),+                None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => {+                    scan::opened_file_faces(&path, MAX_SCAN_READ, source, real)+                }                 None => continue,             };             for f in &mut faces {@@ -703,7 +1008,7 @@ impl Catalog {         state.files = files;         drop(state); -        *lock(&self.stamp) = Stamp { app_dirs, dirs: walk.dirs, registered };+        *lock(&self.stamp) = Stamp { app_dirs, confined_dirs, dirs: walk.dirs, registered };         let added = lock(&self.added);         let scanned = self.scanned();         self.publish(Index::build(&added.faces, &scanned));@@ -729,8 +1034,9 @@ struct Walk {     seen_files: HashSet<PathBuf>,     entries: usize,     dirs: Vec<(PathBuf, Option<SystemTime>)>,-    /// Each file with where it was found and whether a lister named it.-    files: Vec<(PathBuf, Source, bool)>,+    /// Each file with where it was found, whether a lister named it, and whether it is a+    /// confined folder's file recorded by its real path (opened without following a link).+    files: Vec<(PathBuf, Source, bool, bool)>, }  impl Walk {@@ -739,9 +1045,11 @@ impl Walk {         if self.ignore_case { PathBuf::from(real.to_string_lossy().to_lowercase()) } else { real }     } -    fn dir(&mut self, dir: &Path, source: Source, depth: usize) {+    /// Walk `dir` and its subfolders. With `inside` (a confined folder's [`Walk::key`]), folders+    /// and files whose real path is outside it are skipped.+    fn dir(&mut self, dir: &Path, source: Source, depth: usize, inside: Option<&Path>) {         let key = self.key(dir);-        if !self.seen_dirs.insert(key) {+        if inside.is_some_and(|root| !key.starts_with(root)) || !self.seen_dirs.insert(key) {             return;         }         // Stamped before it is listed: a font installed meanwhile changes it after this.@@ -761,18 +1069,29 @@ impl Walk {         for (path, is_dir) in paths {             if is_dir {                 if depth < MAX_DEPTH {-                    self.dir(&path, source, depth + 1);+                    self.dir(&path, source, depth + 1, inside);                 }             } else {-                self.file(path, source, false);+                self.file(path, source, false, inside);             }         }     } -    fn file(&mut self, path: PathBuf, source: Source, by_name: bool) {+    /// Record `path`. In a confined folder its real path is recorded, so a link swapped in after+    /// this check can't lead the scan outside the folder.+    fn file(&mut self, path: PathBuf, source: Source, by_name: bool, inside: Option<&Path>) {         let key = self.key(&path);-        if self.seen_files.insert(key) {-            self.files.push((path, source, by_name));+        if inside.is_some_and(|root| !key.starts_with(root)) || !self.seen_files.insert(key) {+            return;         }+        let (path, real) = match inside {+            // On Windows the real path is a `\\?\` path, which apps would show.+            Some(_) if cfg!(unix) => match std::fs::canonicalize(&path) {+                Ok(real) => (real, true),+                Err(_) => return,+            },+            _ => (path, false),+        };+        self.files.push((path, source, by_name, real));     } }diff --git a/crates/font-catalog/src/lib.rs b/crates/font-catalog/src/lib.rs--- a/crates/font-catalog/src/lib.rs+++ b/crates/font-catalog/src/lib.rs@@ -28,7 +28,7 @@ mod scan; mod suitcase; pub mod system; -pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch};+pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Scope, Watch}; pub use env::{FontEnv, Os, font_dirs, font_store_dir, office_font_dirs}; pub use fontconfig::fontconfig_dirs; pub use scan::{FaceInfo, LocalizedName, Source, is_font_file, style_italic, style_weight, style_width};diff --git a/crates/font-catalog/src/scan.rs b/crates/font-catalog/src/scan.rs--- a/crates/font-catalog/src/scan.rs+++ b/crates/font-catalog/src/scan.rs@@ -356,6 +356,11 @@ pub(crate) fn data_faces(data: &[u8], max_faces: u32, path: Option<&Path>, sourc /// and `OS/2` tables, at most `max_read` bytes in all: a scan opens thousands of files, many of /// them megabytes long. A Mac suitcase font's `sfnt` resources are read instead. pub(crate) fn file_faces(path: &Path, max_read: u64, source: Source) -> Vec<Face> {+    opened_file_faces(path, max_read, source, false)+}++/// [`file_faces`], opening the file with [`open_regular`]'s `no_follow`.+pub(crate) fn opened_file_faces(path: &Path, max_read: u64, source: Source, no_follow: bool) -> Vec<Face> {     if crate::suitcase::is_suitcase(path) {         if crate::suitcase::fork_len(path) > max_read {             return vec![];@@ -369,12 +374,35 @@ pub(crate) fn file_faces(path: &Path, max_read: u64, source: Source) -> Vec<Face         }         return faces;     }-    match std::fs::File::open(path) {+    match open_regular(path, no_follow) {         Ok(file) => reader_faces(file, MAX_FACES, max_read, path, source),         Err(_) => vec![],     } } +pub(crate) fn not_a_regular_file() -> std::io::Error {+    std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file")+}++/// `path` opened for reading, when it is a regular file (after following links). The check is+/// on the open file, so a FIFO or device swapped in after an earlier check is refused too; on+/// Unix the open doesn't wait for a FIFO's writer (`O_NONBLOCK`, which reads of a regular file+/// ignore). With `no_follow`, on Unix a link as the last component is refused (`O_NOFOLLOW`);+/// links in the folders above it are still followed.+pub(crate) fn open_regular(path: &Path, no_follow: bool) -> std::io::Result<std::fs::File> {+    let mut options = std::fs::OpenOptions::new();+    options.read(true);+    #[cfg(unix)]+    std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK | if no_follow { libc::O_NOFOLLOW } else { 0 });+    #[cfg(not(unix))]+    let _ = no_follow;+    let file = options.open(path)?;+    if !file.metadata()?.is_file() {+        return Err(not_a_regular_file());+    }+    Ok(file)+}+ fn reader_faces(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64, path: &Path, source: Source) -> Vec<Face> {     use std::io::SeekFrom;     let mut read = 0u64;diff --git a/crates/font-catalog/src/tests.rs b/crates/font-catalog/src/tests.rs--- a/crates/font-catalog/src/tests.rs+++ b/crates/font-catalog/src/tests.rs@@ -428,6 +428,156 @@ fn added_fonts_come_first_and_respect_the_limits() {     assert!(matches!(cat.load(&bold), Err(FontFileError::Gone))); } +#[test]+fn scoped_fonts_are_seen_only_in_their_scope() {+    let root = temp("scope");+    let cat = system(&root);+    let limits = Limits::default();+    let (a, b) = (cat.new_scope(), cat.new_scope());+    cat.rescan();+    let generation = cat.generation();+    // Each scope replaces the installed Test Sans Bold with its own (told apart by fsType); A also+    // has a family of its own.+    let a_bold = cat.add_bytes_in(a, font(&Spec { fs_type: 4, ..spec("Test Sans", "Bold", 700) }), Source::Document, &limits).unwrap();+    let file = root.join("doc-a/DocOnly.ttf");+    write(&file, font(&spec("Doc Only", "Regular", 400)));+    cat.add_file_in(a, &file, Source::Document, &limits).unwrap();+    cat.add_bytes_in(b, font(&Spec { fs_type: 8, ..spec("Test Sans", "Bold", 700) }), Source::Document, &limits).unwrap();+    assert_eq!(cat.generation(), generation, "scoped changes leave other documents alone");+    assert!(cat.generation_in(a) > generation);++    let bold = |scope: Option<Scope>| {+        let (face, m) = match scope {+            Some(s) => cat.resolve_in(s, "Test Sans", "Bold"),+            None => cat.resolve("Test Sans", "Bold"),+        }+        .unwrap();+        assert_eq!(m, Match::Exact);+        (face.source, face.fs_type)+    };+    assert_eq!(bold(Some(a)), (Source::Document, 4));+    assert_eq!(bold(Some(b)), (Source::Document, 8));+    assert_eq!(bold(None), (Source::System, 0));+    // The scope's family keeps its shared styles, and other families resolve as unscoped.+    assert_eq!(cat.resolve_in(a, "Test Sans", "Regular").unwrap(), cat.resolve("Test Sans", "Regular").unwrap());+    assert_eq!(cat.styles_in(a, "test sans").len(), 5);+    assert_eq!(cat.resolve_in(a, "Host Serif", "Bold").unwrap().0.source, Source::System);+    assert!(cat.families_in(a).iter().any(|f| f == "Doc Only"));+    assert!(!cat.families_in(b).iter().any(|f| f == "Doc Only"));+    assert!(!cat.families().iter().any(|f| f == "Doc Only"));+    assert_eq!(cat.family_in(a, "doc only").as_deref(), Some("Doc Only"));+    assert!(cat.family("Doc Only").is_none());+    assert!(cat.by_postscript_name_in(a, "DocOnly-Regular").is_some());+    assert!(cat.by_postscript_name_in(b, "DocOnly-Regular").is_none());+    assert!(cat.by_postscript_name("DocOnly-Regular").is_none());+    assert!(cat.load_family_in(a, "Test Sans").iter().any(|f| f.info.fs_type == 4));+    assert!(cat.load_family("Test Sans").iter().all(|f| f.info.fs_type == 0));++    // The limit on added faces counts every scope.+    let three = Limits { max_added_faces: 3, ..limits };+    assert!(matches!(cat.add_bytes(font(&spec("One More", "Regular", 400)), Source::User, &three), Err(FontFileError::TooManyFaces { max: 3 })));++    // A face added without a scope comes after the scope's own.+    cat.add_bytes(font(&Spec { fs_type: 2, ..spec("Test Sans", "Bold", 700) }), Source::User, &limits).unwrap();+    assert_eq!(bold(None), (Source::User, 2));+    assert_eq!(bold(Some(b)), (Source::Document, 8));++    let before = cat.generation();+    assert_eq!(cat.remove_scope(a), 2);+    assert_eq!(cat.generation(), before);+    assert_eq!(bold(Some(a)), (Source::User, 2));+    assert_eq!(bold(Some(b)), (Source::Document, 8));+    assert!(cat.family_in(a, "Doc Only").is_none());+    assert!(matches!(cat.load(&a_bold[0]), Err(FontFileError::Gone)));+    // Removing unscoped faces keeps the scopes'.+    assert_eq!(cat.remove_added(Source::User), 1);+    assert_eq!(bold(Some(b)), (Source::Document, 8));+    assert_eq!(cat.load_family_in(b, "Test Sans").iter().filter(|f| f.info.fs_type == 8).count(), 1);+}++#[test]+fn a_scope_resolves_as_one_index_of_every_face_would() {+    let root = temp("scope-names");+    let cat = system(&root);+    let limits = Limits::default();+    let scope = cat.new_scope();+    // A family spelled without the space answers to "Test Sans" only through an alias, so the+    // installed family of that exact name wins there, as it does unscoped.+    let doc = font(&Spec { fs_type: 4, ..spec("TestSans", "Bold", 700) });+    cat.add_bytes_in(scope, doc.clone(), Source::Document, &limits).unwrap();+    assert_eq!(cat.resolve_in(scope, "Test Sans", "Bold"), cat.resolve("Test Sans", "Bold"));+    assert_eq!(cat.styles_in(scope, "Test Sans"), cat.styles("Test Sans"));+    assert_eq!(cat.family_in(scope, "Test Sans").as_deref(), Some("Test Sans"));+    let (own, m) = cat.resolve_in(scope, "TestSans", "Bold").unwrap();+    assert_eq!((own.fs_type, m), (4, Match::Exact));+    assert_eq!(cat.by_postscript_name_in(scope, "TestSans-Bold").unwrap().fs_type, 4);+    assert_eq!(cat.by_postscript_name_in(scope, "TestSans-Regular"), cat.by_postscript_name("TestSans-Regular"));++    // The scope's generation never repeats, removed and added to again included.+    let added = cat.generation_in(scope);+    assert_eq!(cat.remove_scope(scope), 1);+    let removed = cat.generation_in(scope);+    assert_eq!(cat.remove_scope(scope), 0);+    assert_eq!(cat.generation_in(scope), removed);+    assert_eq!(cat.resolve_in(scope, "TestSans", "Bold"), cat.resolve("TestSans", "Bold"));+    cat.add_bytes_in(scope, doc, Source::Document, &limits).unwrap();+    let again = cat.generation_in(scope);+    assert!(added < removed && removed < again, "{added} {removed} {again}");+    assert_eq!(cat.resolve_in(scope, "TestSans", "Bold").unwrap().0.fs_type, 4);+}++#[cfg(unix)]+#[test]+fn the_scan_skips_files_that_are_not_regular() {+    let root = temp("fifo");+    let dir = root.join("usr/share/fonts");+    write(&dir.join("Real.ttf"), font(&spec("Real Face", "Regular", 400)));+    let fifo = dir.join("Blocking.ttf");+    assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());+    let named = fifo.clone();+    let cat = Arc::new(Catalog::new(linux_env(&root)).with_font_service(false).with_lister(Arc::new(move || vec![named.clone()])));+    // Opening a FIFO blocks until a writer comes, so the scan runs on a thread with a deadline.+    let (tx, rx) = mpsc::channel();+    let (scanning, opened) = (Arc::clone(&cat), fifo.clone());+    std::thread::spawn(move || {+        // The open itself doesn't wait, whatever an earlier check said.+        let direct = crate::scan::file_faces(&opened, u64::MAX, Source::User);+        tx.send((direct.len(), scanning.families().to_vec()))+    });+    let (direct, families) = rx.recv_timeout(Duration::from_secs(10)).expect("the scan opened the FIFO");+    assert_eq!((direct, families.as_slice()), (0, ["Real Face".to_string()].as_slice()));+    assert!(matches!(cat.add_file(&fifo, Source::User, &Limits::default()), Err(FontFileError::Io(_))));+    assert!(matches!(cat.add_file(&dir, Source::User, &Limits::default()), Err(FontFileError::Io(_))));+}++#[cfg(unix)]+#[test]+fn a_confined_folder_skips_links_that_leave_it() {+    use std::os::unix::fs::symlink;+    let root = temp("confined");+    let picked = root.join("picked");+    write(&picked.join("Inside.ttf"), font(&spec("Inside Face", "Regular", 400)));+    write(&picked.join("sub/Deep.ttf"), font(&spec("Deep Face", "Regular", 400)));+    write(&root.join("outside/Secret.ttf"), font(&spec("Secret Face", "Regular", 400)));+    write(&root.join("outside/dir/Other.ttf"), font(&spec("Other Face", "Regular", 400)));+    symlink(root.join("outside/Secret.ttf"), picked.join("Secret.ttf")).unwrap();+    symlink(root.join("outside/dir"), picked.join("dir")).unwrap();+    symlink(&picked, root.join("picked-link")).unwrap();+    let cat = Catalog::new(linux_env(&root)).with_font_service(false);+    // The folder may itself be a link; what counts is where it leads.+    cat.set_confined_dirs(vec![root.join("picked-link")]);+    assert_eq!(cat.families().to_vec(), ["Deep Face", "Inside Face"]);+    assert_eq!(cat.styles("Inside Face")[0].source, Source::User);+    // A confined file opens by its real path without following a link swapped in for it.+    assert!(crate::scan::open_regular(&picked.join("Inside.ttf"), true).is_ok());+    assert!(crate::scan::open_regular(&picked.join("Secret.ttf"), true).is_err());+    // As an app folder, the same folder follows its links.+    cat.set_confined_dirs(vec![]);+    cat.set_app_dirs(vec![picked]);+    assert!(cat.rescan_if_changed());+    assert_eq!(cat.families().to_vec(), ["Deep Face", "Inside Face", "Other Face", "Secret Face"]);+}+ #[test] fn the_scan_reads_only_what_it_may() {     let root = temp("bounded");