25.1 KiB · text History e832e77
//! Reconciler unit tests over in-memory fakes.
//!
//! The central property under test is idempotency: applying a manifest brings
//! the system to the desired state (reporting `Changed`), and applying it again
//! against the now-converged system reports `Ok` with no actions.

use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};

use devendra_proto::{
    ApplyResult, ChangeAction, DirResource, FileResource, FileState, Manifest, ServiceResource,
    ServiceState, content_hash,
};

use super::*;

/// In-memory file entry.
#[derive(Clone)]
struct FakeFile {
    content: Vec<u8>,
    mode: u32,
    uid: u32,
    gid: u32,
}

#[derive(Default)]
struct FakeFs {
    files: HashMap<PathBuf, FakeFile>,
    /// Directories: mode/uid/gid tracked via FakeFile (content unused).
    dirs: HashMap<PathBuf, FakeFile>,
    users: HashMap<String, u32>,
    groups: HashMap<String, u32>,
}

impl FakeFs {
    fn with_ids() -> Self {
        let mut fs = FakeFs::default();
        fs.users.insert("root".into(), 0);
        fs.users.insert("app".into(), 1000);
        fs.groups.insert("root".into(), 0);
        fs.groups.insert("app".into(), 1000);
        fs
    }
}

impl FileSystem for FakeFs {
    fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
        Ok(self.files.get(path).map(|f| f.content.clone()))
    }
    fn metadata(&self, path: &Path) -> io::Result<Option<FileMeta>> {
        let entry = self
            .files
            .get(path)
            .map(|f| (f, false))
            .or_else(|| self.dirs.get(path).map(|d| (d, true)));
        Ok(entry.map(|(e, is_dir)| FileMeta {
            mode: e.mode,
            uid: e.uid,
            gid: e.gid,
            is_dir,
        }))
    }
    fn write(&mut self, path: &Path, content: &[u8]) -> io::Result<()> {
        let entry = self.files.entry(path.to_path_buf()).or_insert(FakeFile {
            content: Vec::new(),
            mode: 0o644,
            uid: 0,
            gid: 0,
        });
        entry.content = content.to_vec();
        Ok(())
    }
    fn set_mode(&mut self, path: &Path, mode: u32) -> io::Result<()> {
        let entry = self
            .files
            .get_mut(path)
            .or_else(|| self.dirs.get_mut(path))
            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
        entry.mode = mode;
        Ok(())
    }
    fn chown(&mut self, path: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
        let f = self
            .files
            .get_mut(path)
            .or_else(|| self.dirs.get_mut(path))
            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
        if let Some(u) = uid {
            f.uid = u;
        }
        if let Some(g) = gid {
            f.gid = g;
        }
        Ok(())
    }
    fn create_dir(&mut self, path: &Path) -> io::Result<()> {
        self.dirs.entry(path.to_path_buf()).or_insert(FakeFile {
            content: Vec::new(),
            mode: 0o755,
            uid: 0,
            gid: 0,
        });
        Ok(())
    }
    fn remove_dir(&mut self, path: &Path) -> io::Result<()> {
        self.dirs
            .remove(path)
            .map(|_| ())
            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
    }
    fn remove(&mut self, path: &Path) -> io::Result<()> {
        self.files
            .remove(path)
            .map(|_| ())
            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
    }
    fn resolve_user(&self, name: &str) -> io::Result<u32> {
        self.users
            .get(name)
            .copied()
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user"))
    }
    fn resolve_group(&self, name: &str) -> io::Result<u32> {
        self.groups
            .get(name)
            .copied()
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no group"))
    }
}

/// Records systemd calls and lets a test preset current active/enabled state.
#[derive(Default)]
struct FakeSystemd {
    active: RefCell<HashMap<String, bool>>,
    enabled: RefCell<HashMap<String, bool>>,
    log: RefCell<Vec<String>>,
}

impl FakeSystemd {
    fn set_active(&self, unit: &str, v: bool) {
        self.active.borrow_mut().insert(unit.into(), v);
    }
    fn set_enabled(&self, unit: &str, v: bool) {
        self.enabled.borrow_mut().insert(unit.into(), v);
    }
    fn calls(&self) -> Vec<String> {
        self.log.borrow().clone()
    }
}

impl SystemdController for FakeSystemd {
    fn daemon_reload(&mut self) -> io::Result<()> {
        self.log.borrow_mut().push("daemon-reload".into());
        Ok(())
    }
    fn is_active(&self, unit: &str) -> io::Result<bool> {
        Ok(*self.active.borrow().get(unit).unwrap_or(&false))
    }
    fn is_enabled(&self, unit: &str) -> io::Result<bool> {
        Ok(*self.enabled.borrow().get(unit).unwrap_or(&false))
    }
    fn start(&mut self, unit: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("start {unit}"));
        self.active.borrow_mut().insert(unit.into(), true);
        Ok(())
    }
    fn stop(&mut self, unit: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("stop {unit}"));
        self.active.borrow_mut().insert(unit.into(), false);
        Ok(())
    }
    fn restart(&mut self, unit: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("restart {unit}"));
        self.active.borrow_mut().insert(unit.into(), true);
        Ok(())
    }
    fn reload(&mut self, unit: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("reload {unit}"));
        Ok(())
    }
    fn enable(&mut self, unit: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("enable {unit}"));
        self.enabled.borrow_mut().insert(unit.into(), true);
        Ok(())
    }
    fn disable(&mut self, unit: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("disable {unit}"));
        self.enabled.borrow_mut().insert(unit.into(), false);
        Ok(())
    }
}

/// Records package operations and lets a test preset installed state.
#[derive(Default)]
struct FakePackages {
    installed: RefCell<HashMap<String, bool>>,
    log: RefCell<Vec<String>>,
}

impl FakePackages {
    fn set_installed(&self, name: &str, v: bool) {
        self.installed.borrow_mut().insert(name.into(), v);
    }
    fn calls(&self) -> Vec<String> {
        self.log.borrow().clone()
    }
}

impl PackageManager for FakePackages {
    fn is_installed(&self, name: &str) -> io::Result<bool> {
        Ok(*self.installed.borrow().get(name).unwrap_or(&false))
    }
    fn install(&mut self, name: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("install {name}"));
        self.installed.borrow_mut().insert(name.into(), true);
        Ok(())
    }
    fn remove(&mut self, name: &str) -> io::Result<()> {
        self.log.borrow_mut().push(format!("remove {name}"));
        self.installed.borrow_mut().insert(name.into(), false);
        Ok(())
    }
    fn backend(&self) -> &str {
        "fake"
    }
}

/// Content source backed by a name->bytes map keyed on destination path.
struct MapContent(HashMap<String, Vec<u8>>);

impl ContentSource for MapContent {
    fn fetch(&self, resource: &FileResource) -> io::Result<Vec<u8>> {
        self.0
            .get(&resource.path)
            .cloned()
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no content"))
    }
}

fn present_file(path: &str, body: &[u8], mode: Option<u32>) -> FileResource {
    FileResource {
        path: path.into(),
        state: FileState::Present,
        content_blake3: Some(content_hash(body)),
        mode,
        owner: None,
        group: None,
    }
}

/// Apply twice; assert first pass changes and second pass is a clean no-op.
fn assert_idempotent(
    manifest: &Manifest,
    fs: &mut FakeFs,
    sd: &mut FakeSystemd,
    pkg: &mut FakePackages,
    content: &MapContent,
) {
    let first = Reconciler {
        fs,
        systemd: sd,
        packages: pkg,
        content,
    }
    .apply(manifest);
    assert_ne!(
        first.result,
        ApplyResult::Failed,
        "first apply failed: {:?}",
        first.changes
    );

    sd.log.borrow_mut().clear();
    let second = Reconciler {
        fs,
        systemd: sd,
        packages: pkg,
        content,
    }
    .apply(manifest);
    assert_eq!(
        second.result,
        ApplyResult::Ok,
        "second apply was not a clean no-op: {:?}",
        second.changes
    );
    assert!(
        second.changes.iter().all(|c| !c.action.is_change()),
        "second apply reported changes: {:?}",
        second.changes
    );
    assert!(
        sd.calls().is_empty(),
        "second apply touched systemd: {:?}",
        sd.calls()
    );
}

#[test]
fn installs_quadlet_and_reloads_then_is_idempotent() {
    let body = b"[Container]\nImage=docker.io/library/nginx\n";
    let path = "/etc/containers/systemd/web.container";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![],
        files: vec![present_file(path, body, Some(0o644))],
        services: vec![ServiceResource {
            name: "web.service".into(),
            enabled: None,
            state: Some(ServiceState::Started),
        }],
    };
    let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    // First apply: installs the quadlet, daemon-reloads, starts the service.
    let first = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(first.result, ApplyResult::Changed);
    assert!(sd.calls().contains(&"daemon-reload".to_string()));
    assert!(sd.calls().contains(&"start web.service".to_string()));
    // File landed with correct content + mode.
    assert_eq!(fs.files[Path::new(path)].content, body);
    assert_eq!(fs.files[Path::new(path)].mode, 0o644);

    assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}

#[test]
fn drifted_content_is_rewritten() {
    let body = b"good\n";
    let path = "/etc/myapp/app.conf";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 5,
        packages: vec![],
        directories: vec![],
        files: vec![present_file(path, body, Some(0o600))],
        services: vec![],
    };
    let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
    let mut fs = FakeFs::with_ids();
    // Pre-existing wrong content.
    fs.files.insert(
        PathBuf::from(path),
        FakeFile {
            content: b"stale\n".to_vec(),
            mode: 0o600,
            uid: 0,
            gid: 0,
        },
    );

    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();
    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Changed);
    assert_eq!(r.changes[0].action, ChangeAction::Updated);
    assert_eq!(fs.files[Path::new(path)].content, body);
    // Non-systemd path: no daemon-reload.
    assert!(!sd.calls().contains(&"daemon-reload".to_string()));

    assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}

#[test]
fn absent_file_is_removed_then_stays_absent() {
    let path = "/etc/myapp/old.conf";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 2,
        packages: vec![],
        directories: vec![],
        files: vec![FileResource {
            path: path.into(),
            state: FileState::Absent,
            content_blake3: None,
            mode: None,
            owner: None,
            group: None,
        }],
        services: vec![],
    };
    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    fs.files.insert(
        PathBuf::from(path),
        FakeFile {
            content: b"x".to_vec(),
            mode: 0o644,
            uid: 0,
            gid: 0,
        },
    );
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Changed);
    assert_eq!(r.changes[0].action, ChangeAction::Removed);
    assert!(!fs.files.contains_key(Path::new(path)));

    assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}

#[test]
fn ownership_is_applied_and_idempotent() {
    let body = b"data\n";
    let path = "/etc/myapp/owned.conf";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![],
        files: vec![FileResource {
            path: path.into(),
            state: FileState::Present,
            content_blake3: Some(content_hash(body)),
            mode: Some(0o640),
            owner: Some("app".into()),
            group: Some("app".into()),
        }],
        services: vec![],
    };
    let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Changed);
    let f = &fs.files[Path::new(path)];
    assert_eq!((f.uid, f.gid), (1000, 1000));
    assert_eq!(f.mode, 0o640);

    assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}

#[test]
fn restart_only_fires_when_config_changes() {
    let body = b"[Container]\nImage=x\n";
    let path = "/etc/containers/systemd/api.container";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![],
        files: vec![present_file(path, body, Some(0o644))],
        services: vec![ServiceResource {
            name: "api.service".into(),
            enabled: Some(true),
            state: Some(ServiceState::Restarted),
        }],
    };
    let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    // Pass 1: file is new -> config changed -> restart fires.
    let r1 = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r1.result, ApplyResult::Changed);
    assert!(sd.calls().contains(&"restart api.service".to_string()));
    assert!(sd.calls().contains(&"enable api.service".to_string()));

    // Pass 2: nothing changed -> no restart, unit already active -> unchanged.
    sd.log.borrow_mut().clear();
    let r2 = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r2.result, ApplyResult::Ok);
    assert!(
        !sd.calls().iter().any(|c| c.starts_with("restart")),
        "restart should not fire on a clean pass: {:?}",
        sd.calls()
    );
}

#[test]
fn already_active_service_is_left_alone() {
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![],
        files: vec![],
        services: vec![ServiceResource {
            name: "db.service".into(),
            enabled: Some(true),
            state: Some(ServiceState::Started),
        }],
    };
    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();
    sd.set_active("db.service", true);
    sd.set_enabled("db.service", true);

    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Ok);
    assert!(
        sd.calls().is_empty(),
        "unexpected systemd calls: {:?}",
        sd.calls()
    );
}

#[test]
fn content_hash_mismatch_is_reported_as_failure() {
    let path = "/etc/myapp/x.conf";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![],
        // Manifest claims one hash, but the content source will return other bytes.
        files: vec![present_file(path, b"expected\n", None)],
        services: vec![],
    };
    let content = MapContent(HashMap::from([(path.to_string(), b"WRONG".to_vec())]));
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Failed);
    assert_eq!(r.changes[0].action, ChangeAction::Failed);
    assert!(r.changes[0].detail.contains("hash mismatch"));
}

#[test]
fn directory_is_created_then_idempotent() {
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![DirResource {
            path: "/mnt/hass".into(),
            state: FileState::Present,
            mode: Some(0o750),
            owner: Some("app".into()),
            group: None,
        }],
        files: vec![],
        services: vec![],
    };
    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    // First apply creates the directory with the requested mode + owner.
    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Changed);
    assert_eq!(r.changes[0].action, ChangeAction::Created);
    let meta = fs.metadata(Path::new("/mnt/hass")).unwrap().unwrap();
    assert!(meta.is_dir);
    assert_eq!(meta.mode, 0o750);
    assert_eq!(meta.uid, 1000);

    assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}

#[test]
fn directory_conflicting_with_a_file_fails() {
    let path = "/mnt/hass";
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![DirResource {
            path: path.into(),
            state: FileState::Present,
            mode: None,
            owner: None,
            group: None,
        }],
        files: vec![],
        services: vec![],
    };
    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    // A regular file already occupies the path.
    fs.files.insert(
        PathBuf::from(path),
        FakeFile {
            content: b"x".to_vec(),
            mode: 0o644,
            uid: 0,
            gid: 0,
        },
    );
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();

    let r = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(r.result, ApplyResult::Failed);
    assert!(r.changes[0].detail.contains("not a directory"));
}

#[test]
fn packages_install_and_remove_then_idempotent() {
    let manifest = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![
            devendra_proto::PackageResource {
                name: "mosquitto".into(),
                state: FileState::Present,
            },
            devendra_proto::PackageResource {
                name: "telnet".into(),
                state: FileState::Absent,
            },
        ],
        directories: vec![],
        files: vec![],
        services: vec![],
    };
    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();
    // mosquitto not yet installed; telnet is present and should be removed.
    pkg.set_installed("telnet", true);

    let first = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(first.result, ApplyResult::Changed);
    assert!(pkg.calls().contains(&"install mosquitto".to_string()));
    assert!(pkg.calls().contains(&"remove telnet".to_string()));

    // Second pass: both are in the desired state -> no package operations.
    pkg.log.borrow_mut().clear();
    let second = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply(&manifest);
    assert_eq!(second.result, ApplyResult::Ok);
    assert!(
        pkg.calls().is_empty(),
        "second pass touched packages: {:?}",
        pkg.calls()
    );
}

#[test]
fn pruning_undoes_dropped_file_and_service() {
    let body = b"[Container]\nImage=nginx\n";
    let path = "/etc/containers/systemd/web.container";
    // Prior state managed a quadlet + service.
    let prior = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![],
        directories: vec![],
        files: vec![present_file(path, body, Some(0o644))],
        services: vec![ServiceResource {
            name: "web.service".into(),
            enabled: Some(true),
            state: Some(ServiceState::Started),
        }],
    };
    // New state drops both (as if the fragment was removed).
    let new = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 2,
        packages: vec![],
        directories: vec![],
        files: vec![],
        services: vec![],
    };

    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    // The quadlet is currently installed; the service running + enabled.
    fs.files.insert(
        PathBuf::from(path),
        FakeFile {
            content: body.to_vec(),
            mode: 0o644,
            uid: 0,
            gid: 0,
        },
    );
    let mut sd = FakeSystemd::default();
    sd.set_active("web.service", true);
    sd.set_enabled("web.service", true);
    let mut pkg = FakePackages::default();

    let report = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply_with_prior(&new, Some(&prior));

    assert_eq!(report.result, ApplyResult::Changed);
    // Service stopped + disabled, quadlet removed, daemon-reload run.
    assert!(sd.calls().contains(&"stop web.service".to_string()));
    assert!(sd.calls().contains(&"disable web.service".to_string()));
    assert!(sd.calls().contains(&"daemon-reload".to_string()));
    assert!(
        !fs.files.contains_key(Path::new(path)),
        "quadlet should be removed"
    );

    // Second apply with prior == new: nothing left to prune.
    sd.log.borrow_mut().clear();
    let again = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply_with_prior(&new, Some(&new));
    assert_eq!(again.result, ApplyResult::Ok);
    assert!(
        sd.calls().is_empty(),
        "no prune on a stable state: {:?}",
        sd.calls()
    );
}

#[test]
fn pruning_never_removes_directories_or_packages() {
    let prior = Manifest {
        protocol_version: devendra_proto::PROTOCOL_VERSION,
        generation: 1,
        packages: vec![devendra_proto::PackageResource {
            name: "mosquitto".into(),
            state: FileState::Present,
        }],
        directories: vec![DirResource {
            path: "/mnt/data".into(),
            state: FileState::Present,
            mode: None,
            owner: None,
            group: None,
        }],
        files: vec![],
        services: vec![],
    };
    let new = Manifest::empty(2);

    let content = MapContent(HashMap::new());
    let mut fs = FakeFs::with_ids();
    fs.dirs.insert(
        PathBuf::from("/mnt/data"),
        FakeFile {
            content: vec![],
            mode: 0o755,
            uid: 0,
            gid: 0,
        },
    );
    let mut sd = FakeSystemd::default();
    let mut pkg = FakePackages::default();
    pkg.set_installed("mosquitto", true);

    let report = Reconciler {
        fs: &mut fs,
        systemd: &mut sd,
        packages: &mut pkg,
        content: &content,
    }
    .apply_with_prior(&new, Some(&prior));

    // Data dir preserved; package left installed.
    assert!(
        fs.dirs.contains_key(Path::new("/mnt/data")),
        "dir must not be pruned"
    );
    assert!(
        !pkg.calls().iter().any(|c| c.starts_with("remove")),
        "package must not be pruned"
    );
    assert_eq!(report.result, ApplyResult::Ok);
}