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
144fn is_flat_system_dir(dir: &str) -> bool {
147 matches!(dir, "usr/bin" | "usr/sbin" | "bin" | "sbin") || crate::LIB_DIRS.contains(&dir)
148}
149
150fn 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
165pub 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}