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    /// The paths the run opened itself, relative to the root, without
144    /// the sibling rule [`Trace::keeps`] prunes with. The inference
145    /// trim asks exactly this: a member survives `[bundle]
146    /// trim-inferred` only when the run opened it.
147    pub fn opened_paths(&self) -> &BTreeSet<String> {
148        &self.opened
149    }
150}
151
152/// The directories a distribution keeps flat and full: what a run opened
153/// there says nothing about what sits beside it.
154fn is_flat_system_dir(dir: &str) -> bool {
155    matches!(dir, "usr/bin" | "usr/sbin" | "bin" | "sbin") || crate::LIB_DIRS.contains(&dir)
156}
157
158/// The topmost directory of the Python package `rel` belongs to: every
159/// directory from it down to the file's own holds an `__init__.py`.
160fn package_root<'a>(rel: &'a str, init_dirs: &HashSet<&str>) -> Option<&'a str> {
161    let (mut dir, _) = rel.rsplit_once('/')?;
162    if !init_dirs.contains(dir) {
163        return None;
164    }
165    while let Some((parent, _)) = dir.rsplit_once('/')
166        && init_dirs.contains(parent)
167    {
168        dir = parent;
169    }
170    Some(dir)
171}
172
173/// Apply the tiers to `files`, relative paths in any order. `keep_names`
174/// survive the trace regardless: a basename, the soname some bundled
175/// object needs, keeps every file so named, and a path, holding a slash,
176/// keeps that one file. `keep` are globs the trace may not prune.
177///
178/// A Python package is kept whole once the trace opened anything in it:
179/// its modules are imported lazily, so file granularity there would cut
180/// what the next run needs.
181pub fn prune(
182    files: &[String],
183    platform: Option<&PlatformLine>,
184    policy: Option<&Policy>,
185    trace: Option<&Trace>,
186    keep_names: &HashSet<String>,
187    keep: Option<&Policy>,
188) -> Pruned {
189    let mut out = Pruned::default();
190    let mut sorted: Vec<&String> = files.iter().collect();
191    sorted.sort();
192    sorted.dedup();
193
194    let init_dirs: HashSet<&str> = sorted
195        .iter()
196        .filter_map(|rel| rel.strip_suffix("/__init__.py"))
197        .collect();
198    let opened_packages: HashSet<&str> = trace
199        .map(|t| {
200            t.opened
201                .iter()
202                .filter_map(|rel| package_root(rel, &init_dirs))
203                .collect()
204        })
205        .unwrap_or_default();
206
207    for rel in sorted {
208        let name = rel.rsplit('/').next().unwrap_or(rel);
209        if platform.is_some_and(|p| p.matches(rel)) {
210            out.removed_platform += 1;
211            out.host_provided.push(name.to_string());
212            continue;
213        }
214        if policy.is_some_and(|p| p.matches(rel)) {
215            out.removed_policy += 1;
216            continue;
217        }
218        if let Some(trace) = trace
219            && !trace.keeps(rel)
220            && !keep_names.contains(name)
221            && !keep_names.contains(rel.as_str())
222            && !keep.is_some_and(|k| k.matches(rel))
223            && !package_root(rel, &init_dirs).is_some_and(|root| opened_packages.contains(root))
224        {
225            out.removed_trace += 1;
226            continue;
227        }
228        out.kept.push(rel.clone());
229    }
230    out.host_provided.sort();
231    out.host_provided.dedup();
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::db::fixture::temp_root;
239
240    fn files() -> Vec<String> {
241        [
242            "usr/bin/app",
243            "usr/lib/libfixture.so.1",
244            "usr/lib/libnvidia-glcore.so.550",
245            "usr/lib/plugins/a.so",
246            "usr/lib/plugins/b.so",
247            "usr/share/doc/app/README",
248            "usr/share/locale/de/LC_MESSAGES/app.mo",
249            "usr/share/man/man1/app.1",
250        ]
251        .iter()
252        .map(|s| s.to_string())
253        .collect()
254    }
255
256    #[test]
257    fn the_platform_line_removes_and_records() {
258        let root = temp_root("platform");
259        let path = root.join("platform-line.txt");
260        std::fs::write(&path, "# host drivers\nlibnvidia\nlibGL.so\n").unwrap();
261        let line = PlatformLine::load(&path).unwrap();
262        let out = prune(&files(), Some(&line), None, None, &HashSet::new(), None);
263        assert_eq!(out.removed_platform, 1);
264        assert_eq!(out.host_provided, ["libnvidia-glcore.so.550"]);
265        assert!(!out.kept.iter().any(|f| f.contains("nvidia")));
266        assert_eq!(out.kept.len(), files().len() - 1);
267        assert!(line.matches_soname("libGL.so.1"));
268    }
269
270    #[test]
271    fn the_policy_removes_documentation_and_nothing_else() {
272        let root = temp_root("policy");
273        let path = root.join("policy.txt");
274        std::fs::write(&path, "usr/share/doc/**\n/usr/share/man/**\n").unwrap();
275        let policy = Policy::load(&path).unwrap();
276        let out = prune(&files(), None, Some(&policy), None, &HashSet::new(), None);
277        assert_eq!(out.removed_policy, 2);
278        assert!(out.kept.iter().any(|f| f.ends_with("app.mo")));
279        assert!(!out.kept.iter().any(|f| f.contains("share/doc")));
280        assert!(!out.kept.iter().any(|f| f.contains("share/man")));
281    }
282
283    #[test]
284    fn a_star_stays_within_one_directory() {
285        let root = temp_root("policystar");
286        let path = root.join("policy.txt");
287        std::fs::write(&path, "usr/lib/lib*.so\nusr/share/**/*.mo\n").unwrap();
288        let policy = Policy::load(&path).unwrap();
289        assert!(policy.matches("usr/lib/libfoo.so"));
290        assert!(!policy.matches("usr/lib/libreoffice/program/libmergedlo.so"));
291        assert!(policy.matches("usr/share/locale/de/LC_MESSAGES/app.mo"));
292    }
293
294    #[test]
295    fn a_bad_pattern_names_the_file_and_line() {
296        let root = temp_root("badpolicy");
297        let path = root.join("policy.txt");
298        std::fs::write(&path, "usr/[\n").unwrap();
299        let err = Policy::load(&path).unwrap_err();
300        assert!(err.to_string().contains("usr/["), "{err}");
301    }
302
303    #[test]
304    fn the_trace_keeps_opened_files_their_siblings_and_needed_names() {
305        let trace = Trace::from_paths(vec!["/usr/bin/app".into(), "/usr/lib/plugins/a.so".into()]);
306        let keep: HashSet<String> = ["libfixture.so.1".to_string()].into_iter().collect();
307        let out = prune(&files(), None, None, Some(&trace), &keep, None);
308        assert!(out.kept.contains(&"usr/bin/app".to_string()));
309        assert!(
310            out.kept.contains(&"usr/lib/plugins/b.so".to_string()),
311            "sibling kept"
312        );
313        assert!(
314            out.kept.contains(&"usr/lib/libfixture.so.1".to_string()),
315            "needed kept"
316        );
317        assert!(
318            !out.kept.iter().any(|f| f.ends_with("app.mo")),
319            "unopened removed"
320        );
321        assert_eq!(out.removed_trace, files().len() - 4);
322    }
323
324    #[test]
325    fn a_kept_path_keeps_that_file_alone() {
326        let files: Vec<String> = [
327            "usr/share/icons/a/index.theme",
328            "usr/share/icons/b/index.theme",
329        ]
330        .iter()
331        .map(|s| s.to_string())
332        .collect();
333        let trace = Trace::from_paths(vec!["/usr/bin/app".into()]);
334        let keep: HashSet<String> = ["usr/share/icons/a/index.theme".to_string()]
335            .into_iter()
336            .collect();
337        let out = prune(&files, None, None, Some(&trace), &keep, None);
338        assert_eq!(out.kept, ["usr/share/icons/a/index.theme"]);
339    }
340
341    #[test]
342    fn no_trace_means_no_pruning() {
343        let out = prune(&files(), None, None, None, &HashSet::new(), None);
344        assert_eq!(out.kept.len(), files().len());
345        assert_eq!(out.removed_trace, 0);
346    }
347
348    #[test]
349    fn a_trace_keeps_python_packages_whole_and_honours_keep_globs() {
350        let files: Vec<String> = [
351            "usr/lib/python3/site-packages/pkg/__init__.py",
352            "usr/lib/python3/site-packages/pkg/sub/__init__.py",
353            "usr/lib/python3/site-packages/pkg/sub/lazy.py",
354            "usr/lib/python3/site-packages/other/__init__.py",
355            "usr/lib/python3/site-packages/other/never.py",
356            "usr/share/app/icons/a.svg",
357            "usr/share/app/icons/b.svg",
358            "usr/share/app/data/unused.dat",
359        ]
360        .iter()
361        .map(|s| s.to_string())
362        .collect();
363        let trace = Trace::from_paths(vec![
364            "/usr/lib/python3/site-packages/pkg/__init__.py".into(),
365        ]);
366        let root = temp_root("keep");
367        std::fs::write(root.join("keep.txt"), "usr/share/app/icons/**\n").unwrap();
368        let keep = Policy::load(&root.join("keep.txt")).unwrap();
369        let pruned = prune(
370            &files,
371            None,
372            None,
373            Some(&trace),
374            &HashSet::new(),
375            Some(&keep),
376        );
377        let kept: Vec<&str> = pruned.kept.iter().map(String::as_str).collect();
378        assert!(
379            kept.contains(&"usr/lib/python3/site-packages/pkg/sub/lazy.py"),
380            "{kept:?}"
381        );
382        assert!(
383            !kept.contains(&"usr/lib/python3/site-packages/other/never.py"),
384            "{kept:?}"
385        );
386        assert!(kept.contains(&"usr/share/app/icons/b.svg"), "{kept:?}");
387        assert!(!kept.contains(&"usr/share/app/data/unused.dat"), "{kept:?}");
388        assert_eq!(pruned.removed_trace, 3);
389    }
390}