Skip to main content

podbox/
diff.rs

1use std::collections::BTreeSet;
2use std::process::Command;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::codegen::distros::DistroFamily;
8use crate::config::Config;
9use crate::config::PackageManager;
10
11#[derive(Debug, Clone, Serialize)]
12pub struct DiffResult {
13    /// Packages listed in `config.image.packages.install`.
14    pub config_install: Vec<String>,
15    /// All packages actually present in the container (as reported by the
16    /// native package-manager query).
17    pub container_packages: Vec<String>,
18    /// Packages from `config_install` that are **not** installed.
19    pub missing: Vec<String>,
20    /// Packages present in the container that are not in the base set and
21    /// not in `config_install` — potential drift additions.
22    pub unexpected: Vec<String>,
23    /// Whether any drift was detected.
24    pub has_drift: bool,
25}
26
27/// Compare the packages declared in `config` against what is actually
28/// installed in the running container `name`.
29///
30/// The container must be running — the function runs
31/// `podman exec` internally to query package state.
32pub fn compute(config: &Config, name: &str, username: &str) -> Result<DiffResult> {
33    let manager = resolve_manager(config);
34    let raw = query_packages(name, username, manager)?;
35    let container_packages = parse_package_list(&raw, manager);
36
37    let config_set: BTreeSet<String> = config.image.packages.install.iter().cloned().collect();
38    let container_set: BTreeSet<String> = container_packages.iter().cloned().collect();
39
40    let missing: Vec<String> = config_set.difference(&container_set).cloned().collect();
41
42    // For prebuilt images and registry-based base images only report
43    // missing packages — the unexpected list is always noisy because
44    // these images ship hundreds of packages that aren't in our
45    // base-package reference.
46    let unexpected = if config.image.source().is_prebuilt() || config.image.base.contains('/') {
47        vec![]
48    } else {
49        compute_unexpected(&container_set, &config_set, manager)
50    };
51
52    let has_drift = !missing.is_empty() || !unexpected.is_empty();
53
54    Ok(DiffResult {
55        config_install: config.image.packages.install.clone(),
56        container_packages,
57        missing,
58        unexpected,
59        has_drift,
60    })
61}
62
63/// Map the explicit `manager` field or fall back to distro-based detection.
64fn resolve_manager(config: &Config) -> PackageManager {
65    if config.image.packages.manager == PackageManager::Dnf
66        || config.image.packages.manager == PackageManager::Apt
67        || config.image.packages.manager == PackageManager::Pacman
68        || config.image.packages.manager == PackageManager::Apk
69    {
70        config.image.packages.manager
71    } else {
72        DistroFamily::from_base_image(&config.image.base).manager()
73    }
74    // Zypper is valid but doesn't have a query command yet, so it falls
75    // through to the dnf/rpm default below.
76}
77
78/// Returns the command + arguments used to query *all* installed packages
79/// inside a running container for the given package manager.
80fn query_cmd(manager: PackageManager) -> (&'static str, &'static [&'static str]) {
81    match manager {
82        PackageManager::Apt => ("dpkg-query", &["-W", "-f", "${Package}\n"]),
83        PackageManager::Pacman => ("pacman", &["-Qqn"]),
84        PackageManager::Apk => ("apk", &["list", "-I"]),
85        // dnf / rpm default (also used for Zypper)
86        PackageManager::Dnf | PackageManager::Zypper => {
87            ("rpm", &["-qa", "--queryformat", "%{NAME}\n"])
88        }
89    }
90}
91
92fn query_packages(name: &str, username: &str, manager: PackageManager) -> Result<String> {
93    let (cmd, args) = query_cmd(manager);
94    let output = Command::new("podman")
95        .arg("exec")
96        .arg("-u")
97        .arg(username)
98        .arg(name)
99        .arg(cmd)
100        .args(args)
101        .output()?;
102
103    if !output.status.success() {
104        let stderr = String::from_utf8_lossy(&output.stderr);
105        anyhow::bail!("podman exec {} {} failed: {}", name, cmd, stderr.trim());
106    }
107
108    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
109}
110
111/// Parse a raw package-manager output into a sorted, deduplicated list of
112/// package names.
113fn parse_package_list(raw: &str, manager: PackageManager) -> Vec<String> {
114    let mut pkgs: Vec<String> = raw
115        .lines()
116        .map(str::trim)
117        .filter(|l| !l.is_empty())
118        .filter_map(|l| normalize_package_name(l, manager))
119        .collect();
120    pkgs.sort();
121    pkgs.dedup();
122    pkgs
123}
124
125/// Normalize a single line of package-manager output to a plain package
126/// name.  Returns `None` for lines that should be skipped.
127fn normalize_package_name(line: &str, manager: PackageManager) -> Option<String> {
128    match manager {
129        // apk list -I:  "zlib-1.3.1-r0 x86_64 {zlib}"  →  "zlib"
130        PackageManager::Apk => {
131            if let Some(start) = line.find('{') {
132                let end = line.find('}')?;
133                let name = line[start + 1..end].trim();
134                if !name.is_empty() {
135                    return Some(name.to_string());
136                }
137            }
138            let first = line.split_whitespace().next()?;
139            let name = first.rsplit_once('-').map(|(n, _)| n).unwrap_or(first);
140            Some(name.to_string())
141        }
142        // dpkg-query -W / rpm -qa / pacman -Qqn all give plain names
143        _ => {
144            let name = line.trim();
145            if name.is_empty() {
146                None
147            } else {
148                Some(name.to_string())
149            }
150        }
151    }
152}
153
154/// Determine which container packages are "unexpected" — i.e., not part
155/// of the base image and not declared in `config_install`.
156///
157/// Uses the distro's known base-package set as the reference.
158fn compute_unexpected(
159    container_set: &BTreeSet<String>,
160    config_set: &BTreeSet<String>,
161    manager: PackageManager,
162) -> Vec<String> {
163    let distro = match manager {
164        PackageManager::Apt => DistroFamily::DebianLike,
165        PackageManager::Pacman => DistroFamily::ArchLike,
166        PackageManager::Apk => DistroFamily::AlpineLike,
167        PackageManager::Dnf | PackageManager::Zypper => DistroFamily::FedoraLike,
168    };
169    let base_set: BTreeSet<String> = distro.base_packages(None).into_iter().collect();
170
171    container_set
172        .difference(config_set)
173        .filter(|pkg| !base_set.contains(pkg.as_str()))
174        .cloned()
175        .collect()
176}
177
178/// Format a diff report for display.
179pub fn format_report(result: &DiffResult) -> String {
180    let mut lines = Vec::new();
181
182    if !result.has_drift {
183        lines.push("✓ All declared packages are installed — no drift detected.".to_string());
184        return lines.join("\n");
185    }
186
187    if !result.missing.is_empty() {
188        lines.push("── Declared packages NOT installed ──".to_string());
189        for pkg in &result.missing {
190            lines.push(format!("  - {pkg}"));
191        }
192        lines.push(String::new());
193    }
194
195    if !result.unexpected.is_empty() {
196        lines.push("── Unexpected packages found ──".to_string());
197        let show: Vec<&String> = result.unexpected.iter().take(30).collect();
198        for pkg in &show {
199            lines.push(format!("  + {pkg}"));
200        }
201        if result.unexpected.len() > 30 {
202            lines.push(format!("  … and {} more", result.unexpected.len() - 30));
203        }
204        lines.push(String::new());
205    }
206
207    lines.push(format!(
208        "{} missing, {} unexpected — drift detected.",
209        result.missing.len(),
210        result.unexpected.len(),
211    ));
212
213    lines.join("\n")
214}
215
216/// Patch the `install` array in a TOML definition to match the given
217/// package list.  Returns the patched TOML string.
218///
219/// Uses `toml_edit` to preserve comments and formatting while safely
220/// updating or inserting the `[image.packages].install` key, even when
221/// the array is formatted across multiple lines.
222pub fn patch_toml(original: &str, install: &[String]) -> Result<String> {
223    let mut doc = original
224        .parse::<toml_edit::DocumentMut>()
225        .map_err(|e| anyhow::anyhow!("Failed to parse TOML for editing: {e}"))?;
226
227    let mut arr = toml_edit::Array::new();
228    for pkg in install {
229        arr.push(pkg);
230    }
231
232    doc["image"]["packages"]["install"] = toml_edit::value(arr);
233    Ok(doc.to_string())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    // ---- parse_package_list ----
241
242    #[test]
243    fn parse_rpm_output() {
244        let raw = "bash\ncoreutils\nsudo\nzlib\nbash\n";
245        let pkgs = parse_package_list(raw, PackageManager::Dnf);
246        assert_eq!(pkgs, vec!["bash", "coreutils", "sudo", "zlib"]);
247    }
248
249    #[test]
250    fn parse_dpkg_output() {
251        let raw = "bash\ncoreutils\nsudo\nzlib\n";
252        let pkgs = parse_package_list(raw, PackageManager::Apt);
253        assert_eq!(pkgs, vec!["bash", "coreutils", "sudo", "zlib"]);
254    }
255
256    #[test]
257    fn parse_pacman_output() {
258        let raw = "bash\ncoreutils\nsudo\nzlib\n";
259        let pkgs = parse_package_list(raw, PackageManager::Pacman);
260        assert_eq!(pkgs, vec!["bash", "coreutils", "sudo", "zlib"]);
261    }
262
263    #[test]
264    fn parse_apk_output() {
265        let raw = "zlib-1.3.1-r0 x86_64 {zlib}\nalpine-base-3.20.0 x86_64 {alpine-base}\n";
266        let pkgs = parse_package_list(raw, PackageManager::Apk);
267        assert_eq!(pkgs, vec!["alpine-base", "zlib"]);
268    }
269
270    #[test]
271    fn parse_empty_output() {
272        let pkgs = parse_package_list("", PackageManager::Dnf);
273        assert!(pkgs.is_empty());
274    }
275
276    #[test]
277    fn parse_whitespace_only() {
278        let pkgs = parse_package_list("  \n  \n", PackageManager::Dnf);
279        assert!(pkgs.is_empty());
280    }
281
282    // ---- compute_unexpected ----
283
284    #[test]
285    fn unexpected_excludes_base_packages() {
286        let container: BTreeSet<String> = ["sudo", "curl", "bash", "unrecognized-tool"]
287            .iter()
288            .map(|s| s.to_string())
289            .collect();
290        let config: BTreeSet<String> = ["bash"].iter().map(|s| s.to_string()).collect();
291        // sudo and curl are base packages, should NOT be unexpected.
292        // unrecognized-tool is NOT a base package → unexpected.
293        let unexpected = compute_unexpected(&container, &config, PackageManager::Dnf);
294        assert_eq!(unexpected, vec!["unrecognized-tool"]);
295    }
296
297    // ---- format_report ----
298
299    #[test]
300    fn report_no_drift() {
301        let result = DiffResult {
302            config_install: vec!["git".into()],
303            container_packages: vec!["git".into()],
304            missing: vec![],
305            unexpected: vec![],
306            has_drift: false,
307        };
308        let report = format_report(&result);
309        assert!(report.contains("no drift detected"));
310    }
311
312    #[test]
313    fn report_with_missing() {
314        let result = DiffResult {
315            config_install: vec!["git".into(), "htop".into()],
316            container_packages: vec!["git".into()],
317            missing: vec!["htop".into()],
318            unexpected: vec![],
319            has_drift: true,
320        };
321        let report = format_report(&result);
322        assert!(report.contains("htop"));
323        assert!(report.contains("missing"));
324    }
325
326    // ---- patch_toml ----
327
328    #[test]
329    fn patch_toml_replaces_existing_install() {
330        let original = r#"[image]
331base = "fedora:41"
332name = "myenv"
333
334[image.packages]
335install = ["git", "gcc"]
336remove = []
337"#;
338        let patched = patch_toml(original, &["git".into(), "htop".into()]).unwrap();
339        assert!(patched.contains(r#"install = ["git", "htop"]"#));
340        // Should not duplicate the key
341        assert_eq!(patched.matches("install =").count(), 1);
342    }
343
344    #[test]
345    fn patch_toml_adds_install_when_missing() {
346        let original = r#"[image]
347base = "fedora:41"
348name = "myenv"
349
350[image.packages]
351remove = []
352"#;
353        let patched = patch_toml(original, &["git".into()]).unwrap();
354        assert!(patched.contains(r#"install = ["git"]"#));
355    }
356
357    #[test]
358    fn patch_toml_creates_section_when_absent() {
359        let original = r#"[image]
360base = "fedora:41"
361name = "myenv"
362"#;
363        let patched = patch_toml(original, &["git".into()]).unwrap();
364        assert!(patched.contains(r#"install = ["git"]"#));
365    }
366
367    #[test]
368    fn patch_toml_empty_install() {
369        let original = r#"[image]
370base = "fedora:41"
371name = "myenv"
372
373[image.packages]
374install = ["git"]
375"#;
376        let patched = patch_toml(original, &[] as &[String]).unwrap();
377        assert!(patched.contains(r#"install = []"#));
378    }
379}