Skip to main content

onelf_sysroot/
prune.rs

1//! The three tiers that take files out of a closure.
2//!
3//! A closure is complete by construction and therefore too large. The
4//! platform line names what the host supplies; the policy names what never
5//! ships; a trace, when the publisher has one, names what a test run did
6//! not touch. Each tier is a plain text file so it can be published,
7//! diffed and shared.
8
9use std::collections::{BTreeSet, HashSet};
10use std::io;
11use std::path::Path;
12
13/// Soname prefixes the host is expected to provide.
14#[derive(Debug, Default, Clone)]
15pub struct PlatformLine {
16    prefixes: Vec<String>,
17}
18
19/// Glob patterns over paths relative to the root that never ship.
20#[derive(Debug, Default)]
21pub struct Policy {
22    patterns: Vec<glob::Pattern>,
23}
24
25/// Paths a test run opened, relative to the root.
26#[derive(Debug, Default)]
27pub struct Trace {
28    opened: BTreeSet<String>,
29    opened_dirs: BTreeSet<String>,
30}
31
32/// What pruning kept and why the rest went.
33#[derive(Debug, Default, PartialEq, Eq)]
34pub struct Pruned {
35    pub kept: Vec<String>,
36    /// Basenames of the files the platform line removed.
37    pub host_provided: Vec<String>,
38    pub removed_platform: usize,
39    pub removed_policy: usize,
40    pub removed_trace: usize,
41}
42
43/// Non-empty, non-comment lines of a tier file.
44fn read_lines(path: &Path) -> io::Result<Vec<String>> {
45    let text = std::fs::read_to_string(path)
46        .map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", path.display())))?;
47    Ok(text
48        .lines()
49        .map(str::trim)
50        .filter(|l| !l.is_empty() && !l.starts_with('#'))
51        .map(String::from)
52        .collect())
53}
54
55impl PlatformLine {
56    pub fn load(path: &Path) -> io::Result<Self> {
57        Ok(Self::from_prefixes(read_lines(path)?))
58    }
59
60    pub fn from_prefixes(prefixes: Vec<String>) -> Self {
61        PlatformLine { prefixes }
62    }
63
64    pub fn prefixes(&self) -> &[String] {
65        &self.prefixes
66    }
67
68    /// Whether the file at `rel` is on the platform line, by basename.
69    pub fn matches(&self, rel: &str) -> bool {
70        let name = rel.rsplit('/').next().unwrap_or(rel);
71        self.prefixes.iter().any(|p| name.starts_with(p.as_str()))
72    }
73
74    /// Whether `soname` is on the platform line.
75    pub fn matches_soname(&self, soname: &str) -> bool {
76        self.prefixes.iter().any(|p| soname.starts_with(p.as_str()))
77    }
78}
79
80impl Policy {
81    pub fn load(path: &Path) -> io::Result<Self> {
82        let mut patterns = Vec::new();
83        for line in read_lines(path)? {
84            let pattern = glob::Pattern::new(line.trim_start_matches('/')).map_err(|e| {
85                io::Error::new(
86                    io::ErrorKind::InvalidData,
87                    format!("{}: {line}: {e}", path.display()),
88                )
89            })?;
90            patterns.push(pattern);
91        }
92        Ok(Policy { patterns })
93    }
94
95    /// Whether `rel` matches a pattern. `*` stays within one directory
96    /// and `**` crosses them, so `usr/lib/*.so` names nothing in a
97    /// subdirectory of `usr/lib`.
98    pub fn matches(&self, rel: &str) -> bool {
99        let options = glob::MatchOptions {
100            case_sensitive: true,
101            require_literal_separator: true,
102            require_literal_leading_dot: false,
103        };
104        self.patterns.iter().any(|p| p.matches_with(rel, options))
105    }
106}
107
108impl Trace {
109    /// Read a trace: one path per line, absolute or relative to the root.
110    pub fn load(path: &Path) -> io::Result<Self> {
111        Ok(Self::from_paths(read_lines(path)?))
112    }
113
114    pub fn from_paths(paths: Vec<String>) -> Self {
115        let mut trace = Trace::default();
116        for p in paths {
117            let rel = p.trim_start_matches('/').to_string();
118            if let Some((dir, _)) = rel.rsplit_once('/') {
119                trace.opened_dirs.insert(dir.to_string());
120            }
121            trace.opened.insert(rel);
122        }
123        trace
124    }
125
126    /// Whether `rel` survives: it was opened, or a file beside it was.
127    ///
128    /// The sibling rule is for plugin directories, where a run loads one
129    /// member by name and the next run another. The flat system
130    /// directories hold everything and are always touched, so there it
131    /// would keep all of them; a library or a tool in one of those
132    /// survives on its own account only.
133    fn keeps(&self, rel: &str) -> bool {
134        if self.opened.contains(rel) {
135            return true;
136        }
137        match rel.rsplit_once('/') {
138            Some((dir, _)) => !is_flat_system_dir(dir) && self.opened_dirs.contains(dir),
139            None => false,
140        }
141    }
142}
143
144/// The directories a distribution keeps flat and full: what a run opened
145/// there says nothing about what sits beside it.
146fn is_flat_system_dir(dir: &str) -> bool {
147    matches!(dir, "usr/bin" | "usr/sbin" | "bin" | "sbin") || crate::LIB_DIRS.contains(&dir)
148}
149
150/// The topmost directory of the Python package `rel` belongs to: every
151/// directory from it down to the file's own holds an `__init__.py`.
152fn package_root<'a>(rel: &'a str, init_dirs: &HashSet<&str>) -> Option<&'a str> {
153    let (mut dir, _) = rel.rsplit_once('/')?;
154    if !init_dirs.contains(dir) {
155        return None;
156    }
157    while let Some((parent, _)) = dir.rsplit_once('/')
158        && init_dirs.contains(parent)
159    {
160        dir = parent;
161    }
162    Some(dir)
163}
164
165/// Apply the tiers to `files`, relative paths in any order. `keep_names`
166/// survive the trace regardless: a basename, the soname some bundled
167/// object needs, keeps every file so named, and a path, holding a slash,
168/// keeps that one file. `keep` are globs the trace may not prune.
169///
170/// A Python package is kept whole once the trace opened anything in it:
171/// its modules are imported lazily, so file granularity there would cut
172/// what the next run needs.
173pub fn prune(
174    files: &[String],
175    platform: Option<&PlatformLine>,
176    policy: Option<&Policy>,
177    trace: Option<&Trace>,
178    keep_names: &HashSet<String>,
179    keep: Option<&Policy>,
180) -> Pruned {
181    let mut out = Pruned::default();
182    let mut sorted: Vec<&String> = files.iter().collect();
183    sorted.sort();
184    sorted.dedup();
185
186    let init_dirs: HashSet<&str> = sorted
187        .iter()
188        .filter_map(|rel| rel.strip_suffix("/__init__.py"))
189        .collect();
190    let opened_packages: HashSet<&str> = trace
191        .map(|t| {
192            t.opened
193                .iter()
194                .filter_map(|rel| package_root(rel, &init_dirs))
195                .collect()
196        })
197        .unwrap_or_default();
198
199    for rel in sorted {
200        let name = rel.rsplit('/').next().unwrap_or(rel);
201        if platform.is_some_and(|p| p.matches(rel)) {
202            out.removed_platform += 1;
203            out.host_provided.push(name.to_string());
204            continue;
205        }
206        if policy.is_some_and(|p| p.matches(rel)) {
207            out.removed_policy += 1;
208            continue;
209        }
210        if let Some(trace) = trace
211            && !trace.keeps(rel)
212            && !keep_names.contains(name)
213            && !keep_names.contains(rel.as_str())
214            && !keep.is_some_and(|k| k.matches(rel))
215            && !package_root(rel, &init_dirs).is_some_and(|root| opened_packages.contains(root))
216        {
217            out.removed_trace += 1;
218            continue;
219        }
220        out.kept.push(rel.clone());
221    }
222    out.host_provided.sort();
223    out.host_provided.dedup();
224    out
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::db::fixture::temp_root;
231
232    fn files() -> Vec<String> {
233        [
234            "usr/bin/app",
235            "usr/lib/libfixture.so.1",
236            "usr/lib/libnvidia-glcore.so.550",
237            "usr/lib/plugins/a.so",
238            "usr/lib/plugins/b.so",
239            "usr/share/doc/app/README",
240            "usr/share/locale/de/LC_MESSAGES/app.mo",
241            "usr/share/man/man1/app.1",
242        ]
243        .iter()
244        .map(|s| s.to_string())
245        .collect()
246    }
247
248    #[test]
249    fn the_platform_line_removes_and_records() {
250        let root = temp_root("platform");
251        let path = root.join("platform-line.txt");
252        std::fs::write(&path, "# host drivers\nlibnvidia\nlibGL.so\n").unwrap();
253        let line = PlatformLine::load(&path).unwrap();
254        let out = prune(&files(), Some(&line), None, None, &HashSet::new(), None);
255        assert_eq!(out.removed_platform, 1);
256        assert_eq!(out.host_provided, ["libnvidia-glcore.so.550"]);
257        assert!(!out.kept.iter().any(|f| f.contains("nvidia")));
258        assert_eq!(out.kept.len(), files().len() - 1);
259        assert!(line.matches_soname("libGL.so.1"));
260    }
261
262    #[test]
263    fn the_policy_removes_documentation_and_nothing_else() {
264        let root = temp_root("policy");
265        let path = root.join("policy.txt");
266        std::fs::write(&path, "usr/share/doc/**\n/usr/share/man/**\n").unwrap();
267        let policy = Policy::load(&path).unwrap();
268        let out = prune(&files(), None, Some(&policy), None, &HashSet::new(), None);
269        assert_eq!(out.removed_policy, 2);
270        assert!(out.kept.iter().any(|f| f.ends_with("app.mo")));
271        assert!(!out.kept.iter().any(|f| f.contains("share/doc")));
272        assert!(!out.kept.iter().any(|f| f.contains("share/man")));
273    }
274
275    #[test]
276    fn a_star_stays_within_one_directory() {
277        let root = temp_root("policystar");
278        let path = root.join("policy.txt");
279        std::fs::write(&path, "usr/lib/lib*.so\nusr/share/**/*.mo\n").unwrap();
280        let policy = Policy::load(&path).unwrap();
281        assert!(policy.matches("usr/lib/libfoo.so"));
282        assert!(!policy.matches("usr/lib/libreoffice/program/libmergedlo.so"));
283        assert!(policy.matches("usr/share/locale/de/LC_MESSAGES/app.mo"));
284    }
285
286    #[test]
287    fn a_bad_pattern_names_the_file_and_line() {
288        let root = temp_root("badpolicy");
289        let path = root.join("policy.txt");
290        std::fs::write(&path, "usr/[\n").unwrap();
291        let err = Policy::load(&path).unwrap_err();
292        assert!(err.to_string().contains("usr/["), "{err}");
293    }
294
295    #[test]
296    fn the_trace_keeps_opened_files_their_siblings_and_needed_names() {
297        let trace = Trace::from_paths(vec!["/usr/bin/app".into(), "/usr/lib/plugins/a.so".into()]);
298        let keep: HashSet<String> = ["libfixture.so.1".to_string()].into_iter().collect();
299        let out = prune(&files(), None, None, Some(&trace), &keep, None);
300        assert!(out.kept.contains(&"usr/bin/app".to_string()));
301        assert!(
302            out.kept.contains(&"usr/lib/plugins/b.so".to_string()),
303            "sibling kept"
304        );
305        assert!(
306            out.kept.contains(&"usr/lib/libfixture.so.1".to_string()),
307            "needed kept"
308        );
309        assert!(
310            !out.kept.iter().any(|f| f.ends_with("app.mo")),
311            "unopened removed"
312        );
313        assert_eq!(out.removed_trace, files().len() - 4);
314    }
315
316    #[test]
317    fn a_kept_path_keeps_that_file_alone() {
318        let files: Vec<String> = [
319            "usr/share/icons/a/index.theme",
320            "usr/share/icons/b/index.theme",
321        ]
322        .iter()
323        .map(|s| s.to_string())
324        .collect();
325        let trace = Trace::from_paths(vec!["/usr/bin/app".into()]);
326        let keep: HashSet<String> = ["usr/share/icons/a/index.theme".to_string()]
327            .into_iter()
328            .collect();
329        let out = prune(&files, None, None, Some(&trace), &keep, None);
330        assert_eq!(out.kept, ["usr/share/icons/a/index.theme"]);
331    }
332
333    #[test]
334    fn no_trace_means_no_pruning() {
335        let out = prune(&files(), None, None, None, &HashSet::new(), None);
336        assert_eq!(out.kept.len(), files().len());
337        assert_eq!(out.removed_trace, 0);
338    }
339
340    #[test]
341    fn a_trace_keeps_python_packages_whole_and_honours_keep_globs() {
342        let files: Vec<String> = [
343            "usr/lib/python3/site-packages/pkg/__init__.py",
344            "usr/lib/python3/site-packages/pkg/sub/__init__.py",
345            "usr/lib/python3/site-packages/pkg/sub/lazy.py",
346            "usr/lib/python3/site-packages/other/__init__.py",
347            "usr/lib/python3/site-packages/other/never.py",
348            "usr/share/app/icons/a.svg",
349            "usr/share/app/icons/b.svg",
350            "usr/share/app/data/unused.dat",
351        ]
352        .iter()
353        .map(|s| s.to_string())
354        .collect();
355        let trace = Trace::from_paths(vec![
356            "/usr/lib/python3/site-packages/pkg/__init__.py".into(),
357        ]);
358        let root = temp_root("keep");
359        std::fs::write(root.join("keep.txt"), "usr/share/app/icons/**\n").unwrap();
360        let keep = Policy::load(&root.join("keep.txt")).unwrap();
361        let pruned = prune(
362            &files,
363            None,
364            None,
365            Some(&trace),
366            &HashSet::new(),
367            Some(&keep),
368        );
369        let kept: Vec<&str> = pruned.kept.iter().map(String::as_str).collect();
370        assert!(
371            kept.contains(&"usr/lib/python3/site-packages/pkg/sub/lazy.py"),
372            "{kept:?}"
373        );
374        assert!(
375            !kept.contains(&"usr/lib/python3/site-packages/other/never.py"),
376            "{kept:?}"
377        );
378        assert!(kept.contains(&"usr/share/app/icons/b.svg"), "{kept:?}");
379        assert!(!kept.contains(&"usr/share/app/data/unused.dat"), "{kept:?}");
380        assert_eq!(pruned.removed_trace, 3);
381    }
382}