1use std::collections::{BTreeSet, HashSet};
10use std::io;
11use std::path::Path;
12
13#[derive(Debug, Default, Clone)]
15pub struct PlatformLine {
16 prefixes: Vec<String>,
17}
18
19#[derive(Debug, Default)]
21pub struct Policy {
22 patterns: Vec<glob::Pattern>,
23}
24
25#[derive(Debug, Default)]
27pub struct Trace {
28 opened: BTreeSet<String>,
29 opened_dirs: BTreeSet<String>,
30}
31
32#[derive(Debug, Default, PartialEq, Eq)]
34pub struct Pruned {
35 pub kept: Vec<String>,
36 pub host_provided: Vec<String>,
38 pub removed_platform: usize,
39 pub removed_policy: usize,
40 pub removed_trace: usize,
41}
42
43fn 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 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 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 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 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 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 pub fn opened_paths(&self) -> &BTreeSet<String> {
148 &self.opened
149 }
150}
151
152fn is_flat_system_dir(dir: &str) -> bool {
155 matches!(dir, "usr/bin" | "usr/sbin" | "bin" | "sbin") || crate::LIB_DIRS.contains(&dir)
156}
157
158fn 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
173pub 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}