Skip to main content

horto_os_ui_shared/ops/
backup.rs

1//! Config and disk backup ops.
2//!
3//! reference: horto-os/scripts/s3_backup_etc_configs.sh
4//! reference: horto-os/scripts/timestamped_backup_etc_configs.sh
5//! reference: horto-os/scripts/partclone_backup_sda5.sh
6//! reference: horto-os/docs/BACKUP_YOUR_HORTO-OS.MD
7
8use crate::context::{HostContext, PlannedAction};
9use crate::embed;
10use crate::error::{HortoError, Result};
11use crate::kits::{apt, fs};
12use chrono::Local;
13use serde::{Deserialize, Serialize};
14use std::fs as stdfs;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18/// Result of copying managed `/etc` entries into a backup tree.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct EtcBackupReport {
21    /// Destination directory that received copies.
22    pub dest: String,
23    /// Relative paths successfully copied.
24    pub copied: Vec<String>,
25    /// Expected paths missing on the host.
26    pub skipped_missing: Vec<String>,
27    /// Control env files intentionally skipped.
28    pub skipped_control: Vec<String>,
29}
30
31/// Snapshot of backup-related host state for status API / TUI.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BackupStatus {
34    /// True when the initial setup backup tree exists.
35    pub initial_setup_present: bool,
36    /// Timestamped `/etc` backup directory names.
37    pub timestamped: Vec<String>,
38    /// Disk / partclone readiness probe.
39    pub disk: DiskBackupProbe,
40}
41
42/// Options for a partclone-based eMMC partition backup.
43#[derive(Debug, Clone)]
44pub struct DiskBackupOpts {
45    /// Block device or partition to image.
46    pub source: PathBuf,
47    /// Directory that receives image files.
48    pub dest_dir: PathBuf,
49    /// When true, also capture early boot sectors.
50    pub include_boot_sectors: bool,
51    /// When true, allow apply even if root is not clearly an SD/USB boot.
52    /// Still refuses when root is clearly the source eMMC (`mmcblk0`).
53    pub force: bool,
54}
55
56impl Default for DiskBackupOpts {
57    fn default() -> Self {
58        Self {
59            source: PathBuf::from("/dev/mmcblk0p1"),
60            dest_dir: PathBuf::from("/mnt/external/horto-os"),
61            include_boot_sectors: false,
62            force: false,
63        }
64    }
65}
66
67/// Boolean disk-backup readiness flags (flattened in JSON).
68#[allow(clippy::struct_excessive_bools)]
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DiskBackupFlags {
71    /// True when root looks like onboard eMMC.
72    pub looks_like_emmc_root: bool,
73    /// True when root looks like removable media.
74    pub looks_like_removable_root: bool,
75    /// `partclone.*` tools present on PATH.
76    pub partclone_present: bool,
77    /// `gzip` present on PATH.
78    pub gzip_present: bool,
79    /// Backup source path exists.
80    pub source_exists: bool,
81    /// Destination directory exists.
82    pub dest_dir_exists: bool,
83    /// True when apply is considered safe given probes.
84    pub safe_to_apply: bool,
85}
86
87/// Readiness probe for full-disk / partition image backup.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DiskBackupProbe {
90    /// Resolved root device / mount source label.
91    pub root_source: String,
92    /// Flattened probe flags (same JSON field names as before nesting).
93    #[serde(flatten)]
94    pub flags: DiskBackupFlags,
95    /// Human-readable blockers when unsafe.
96    pub blockers: Vec<String>,
97    /// Non-blocking notes for operators.
98    pub notes: Vec<String>,
99}
100
101impl std::ops::Deref for DiskBackupProbe {
102    type Target = DiskBackupFlags;
103
104    fn deref(&self) -> &Self::Target {
105        &self.flags
106    }
107}
108
109/// Options for shrink-backup wrapper (optional third method).
110#[derive(Debug, Clone)]
111pub struct ShrinkBackupOpts {
112    /// Output image path for the shrink tool.
113    pub dest_img: PathBuf,
114    /// Pass force through to the wrapper.
115    pub force: bool,
116}
117
118fn should_skip_control(name: &str) -> bool {
119    let path = Path::new(name);
120    (name.starts_with("my_")
121        && path
122            .extension()
123            .is_some_and(|ext| ext.eq_ignore_ascii_case("env")))
124        || name == "minimal_setup_vars.env"
125        || name == "my_variables.env"
126        || name == "iot-lan_conf.env"
127        || name == "os-configuration.env"
128}
129
130/// Copy managed `/etc` paths (from embedded config top-level names) into `dest`.
131///
132/// # Errors
133///
134/// Returns [`HortoError`] when directory creation or copy I/O fails.
135pub fn copy_managed_etc(ctx: &mut HostContext, dest: &Path) -> Result<EtcBackupReport> {
136    fs::ensure_dir(ctx, dest)?;
137    let etc = ctx.paths.etc.clone();
138    let mut copied = Vec::new();
139    let mut skipped_missing = Vec::new();
140    let mut skipped_control = Vec::new();
141
142    for entry in embed::config_top_entries() {
143        if should_skip_control(&entry) {
144            ctx.log(format!("Skipping control file from config/: {entry}"));
145            skipped_control.push(entry);
146            continue;
147        }
148        let src = etc.join(&entry);
149        let target = dest.join(&entry);
150        if !src.exists() {
151            ctx.log(format!("Skipping missing path: {}", src.display()));
152            skipped_missing.push(entry);
153            continue;
154        }
155        if src.is_dir() {
156            fs::copy_dir_contents(ctx, &src, &target)?;
157            ctx.log(format!(
158                "Backed up directory: {} -> {}",
159                src.display(),
160                target.display()
161            ));
162        } else {
163            fs::copy_file(ctx, &src, &target)?;
164            ctx.log(format!(
165                "Backed up file: {} -> {}",
166                src.display(),
167                target.display()
168            ));
169        }
170        copied.push(entry);
171    }
172
173    Ok(EtcBackupReport {
174        dest: dest.display().to_string(),
175        copied,
176        skipped_missing,
177        skipped_control,
178    })
179}
180
181/// Protected initial `/etc` backup (same destination as setup step s3).
182///
183/// # Errors
184///
185/// Returns [`HortoError`] when the backup copy fails.
186pub fn backup_etc_initial(ctx: &mut HostContext) -> Result<EtcBackupReport> {
187    let dest = ctx.paths.initial_backup_etc();
188    let report = copy_managed_etc(ctx, &dest)?;
189    ctx.log(format!("Initial /etc backup complete: {}", report.dest));
190    Ok(report)
191}
192
193/// Timestamped `/etc` backup under `/srv/backup/etc/YYYYMMDD-HHMMSS`.
194///
195/// # Errors
196///
197/// Returns [`HortoError`] when the backup copy fails.
198pub fn backup_etc_timestamped(ctx: &mut HostContext) -> Result<EtcBackupReport> {
199    let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
200    let dest = ctx.paths.backup.join("etc").join(&stamp);
201    let report = copy_managed_etc(ctx, &dest)?;
202    ctx.log(format!("Timestamped /etc backup complete: {}", report.dest));
203    Ok(report)
204}
205
206/// List timestamped backup directory names (newest last).
207#[must_use]
208pub fn list_timestamped_etc_backups(ctx: &HostContext) -> Vec<String> {
209    let root = ctx.paths.backup.join("etc");
210    let mut names = Vec::new();
211    let Ok(entries) = stdfs::read_dir(&root) else {
212        return names;
213    };
214    for entry in entries.flatten() {
215        let name = entry.file_name().to_string_lossy().to_string();
216        if name == "initial_setup" {
217            continue;
218        }
219        if entry.path().is_dir() {
220            names.push(name);
221        }
222    }
223    names.sort();
224    names
225}
226
227/// Aggregate backup status for API / dashboard.
228#[must_use]
229pub fn backup_status(ctx: &HostContext) -> BackupStatus {
230    let disk = probe_disk_backup(&DiskBackupOpts::default());
231    BackupStatus {
232        initial_setup_present: ctx.paths.initial_backup_etc().is_dir(),
233        timestamped: list_timestamped_etc_backups(ctx),
234        disk,
235    }
236}
237
238/// Resolve what `/` is mounted from (best effort).
239#[must_use]
240pub fn root_mount_source() -> String {
241    if let Ok(out) = Command::new("findmnt")
242        .args(["-n", "-o", "SOURCE", "/"])
243        .output()
244    {
245        if out.status.success() {
246            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
247            if !s.is_empty() {
248                return s;
249            }
250        }
251    }
252    stdfs::read_to_string("/proc/mounts")
253        .ok()
254        .and_then(|text| {
255            for line in text.lines() {
256                let mut parts = line.split_whitespace();
257                let src = parts.next()?;
258                let tgt = parts.next()?;
259                if tgt == "/" {
260                    return Some(src.to_string());
261                }
262            }
263            None
264        })
265        .unwrap_or_else(|| "unknown".into())
266}
267
268fn looks_like_emmc_root(root: &str) -> bool {
269    root.contains("mmcblk0")
270}
271
272fn looks_like_removable_root(root: &str) -> bool {
273    root.contains("mmcblk1") || root.contains("/dev/sd") || root.contains("usb")
274}
275
276/// Probe whether a partclone eMMC backup is safe to run now.
277#[must_use]
278pub fn probe_disk_backup(opts: &DiskBackupOpts) -> DiskBackupProbe {
279    let root_source = root_mount_source();
280    let looks_like_emmc_root = looks_like_emmc_root(&root_source);
281    let looks_like_removable_root = looks_like_removable_root(&root_source);
282    let partclone_present = which::which("partclone.ext4").is_ok();
283    let gzip_present = which::which("gzip").is_ok();
284    let source_exists = opts.source.exists();
285    let dest_dir_exists = opts.dest_dir.is_dir();
286
287    let mut blockers = Vec::new();
288    let mut notes = Vec::new();
289
290    notes.push(
291        "Full OS image backup must run from a temporary SD/USB boot, not from the live eMMC."
292            .into(),
293    );
294    notes.push(format!(
295        "Default source {} → gzip image under {}",
296        opts.source.display(),
297        opts.dest_dir.display()
298    ));
299
300    if looks_like_emmc_root {
301        blockers.push(format!(
302            "Root is mounted from {root_source} (looks like eMMC). Boot from SD/USB first."
303        ));
304    } else if !looks_like_removable_root {
305        blockers.push(format!(
306            "Root source {root_source} is not clearly SD/USB; refuse unless --force (still blocked on eMMC)."
307        ));
308        notes.push(
309            "Use --force only when you verified you are not booted from the target eMMC.".into(),
310        );
311    }
312
313    if !partclone_present {
314        blockers.push("partclone.ext4 not found (apt install partclone).".into());
315    }
316    if !gzip_present {
317        blockers.push("gzip not found on PATH.".into());
318    }
319    if !source_exists {
320        blockers.push(format!("Source device missing: {}", opts.source.display()));
321    }
322    if !dest_dir_exists {
323        blockers.push(format!(
324            "Destination directory missing (mount USB and mkdir): {}",
325            opts.dest_dir.display()
326        ));
327    }
328
329    // --force clears only the "not clearly removable" class. eMMC root never applies.
330    let safe_to_apply = if looks_like_emmc_root {
331        false
332    } else if opts.force {
333        partclone_present && gzip_present && source_exists && dest_dir_exists
334    } else {
335        blockers.is_empty()
336    };
337
338    DiskBackupProbe {
339        root_source,
340        flags: DiskBackupFlags {
341            looks_like_emmc_root,
342            looks_like_removable_root,
343            partclone_present,
344            gzip_present,
345            source_exists,
346            dest_dir_exists,
347            safe_to_apply,
348        },
349        blockers,
350        notes,
351    }
352}
353
354fn disk_image_name() -> String {
355    format!("emmc_p1_backup_{}.img.gz", Local::now().format("%Y%m%d"))
356}
357
358/// Plan actions for a partclone backup (always safe to call).
359pub fn plan_disk_backup(ctx: &mut HostContext, opts: &DiskBackupOpts) -> Vec<PlannedAction> {
360    let probe = probe_disk_backup(opts);
361    for n in &probe.notes {
362        ctx.plan_action(format!("note: {n}"));
363    }
364    for b in &probe.blockers {
365        ctx.plan_action(format!("blocker: {b}"));
366    }
367    let img = opts.dest_dir.join(disk_image_name());
368    ctx.plan_action(format!(
369        "partclone.ext4 -c -s {} | gzip -c > {}",
370        opts.source.display(),
371        img.display()
372    ));
373    if opts.include_boot_sectors {
374        let boot = opts.dest_dir.join("emmc_bootsectors_backup.img");
375        ctx.plan_action(format!(
376            "dd if=/dev/mmcblk0 of={} bs=1M count=4",
377            boot.display()
378        ));
379    }
380    ctx.planned.clone()
381}
382
383/// Apply partclone backup when guards pass.
384///
385/// # Errors
386///
387/// Returns [`HortoError`] when probes block the run, the operator cancels, apt/partclone/gzip fails, or I/O fails.
388pub fn backup_disk(ctx: &mut HostContext, opts: &DiskBackupOpts) -> Result<PathBuf> {
389    let probe = probe_disk_backup(opts);
390    if ctx.is_dry_run() {
391        plan_disk_backup(ctx, opts);
392        return Ok(opts.dest_dir.join(disk_image_name()));
393    }
394    if !probe.safe_to_apply {
395        return Err(HortoError::msg(format!(
396            "disk backup blocked: {}",
397            probe.blockers.join("; ")
398        )));
399    }
400    if !ctx.confirm(
401        &format!(
402            "Create partclone image of {} into {}?",
403            opts.source.display(),
404            opts.dest_dir.display()
405        ),
406        false,
407    ) {
408        return Err(HortoError::msg("disk backup cancelled"));
409    }
410
411    if !probe.partclone_present {
412        apt::apt_update(ctx)?;
413        apt::apt_install(ctx, &["partclone"])?;
414    }
415
416    fs::ensure_dir(ctx, &opts.dest_dir)?;
417    let img = opts.dest_dir.join(disk_image_name());
418    ctx.log(format!(
419        "Starting partclone of {} → {}",
420        opts.source.display(),
421        img.display()
422    ));
423    run_partclone_gzip(&opts.source, &img)?;
424    ctx.log(format!("Wrote {}", img.display()));
425
426    if opts.include_boot_sectors {
427        let boot = opts.dest_dir.join("emmc_bootsectors_backup.img");
428        let status = Command::new("dd")
429            .args([
430                "if=/dev/mmcblk0",
431                &format!("of={}", boot.display()),
432                "bs=1M",
433                "count=4",
434                "status=progress",
435            ])
436            .status()
437            .map_err(|e| HortoError::msg(format!("dd boot sectors: {e}")))?;
438        if !status.success() {
439            return Err(HortoError::msg("dd boot sectors failed"));
440        }
441        ctx.log(format!("Wrote boot sectors {}", boot.display()));
442    }
443    Ok(img)
444}
445
446fn run_partclone_gzip(source: &Path, dest_img: &Path) -> Result<()> {
447    let out = stdfs::File::create(dest_img)?;
448    let mut partclone = Command::new("partclone.ext4")
449        .args(["-c", "-s", &source.display().to_string()])
450        .stdout(Stdio::piped())
451        .stderr(Stdio::inherit())
452        .spawn()
453        .map_err(|e| HortoError::msg(format!("partclone.ext4: {e}")))?;
454    let stdout = partclone
455        .stdout
456        .take()
457        .ok_or_else(|| HortoError::msg("partclone stdout missing"))?;
458    let gzip = Command::new("gzip")
459        .arg("-c")
460        .stdin(stdout)
461        .stdout(out)
462        .stderr(Stdio::inherit())
463        .status()
464        .map_err(|e| HortoError::msg(format!("gzip: {e}")))?;
465    let part_status = partclone
466        .wait()
467        .map_err(|e| HortoError::msg(format!("partclone wait: {e}")))?;
468    if !part_status.success() {
469        return Err(HortoError::msg("partclone.ext4 failed"));
470    }
471    if !gzip.success() {
472        return Err(HortoError::msg("gzip failed while writing image"));
473    }
474    Ok(())
475}
476
477/// Plan or run shrink-backup when the tool is on PATH.
478///
479/// # Errors
480///
481/// Returns [`HortoError`] when probes block the run, the tool is missing, or the wrapper fails.
482pub fn backup_shrink(ctx: &mut HostContext, opts: &ShrinkBackupOpts) -> Result<PathBuf> {
483    let probe = probe_disk_backup(&DiskBackupOpts {
484        dest_dir: opts
485            .dest_img
486            .parent()
487            .unwrap_or_else(|| Path::new("."))
488            .to_path_buf(),
489        force: opts.force,
490        ..DiskBackupOpts::default()
491    });
492    let bin = which::which("shrink-backup").ok();
493    if ctx.is_dry_run() {
494        for b in &probe.blockers {
495            ctx.plan_action(format!("blocker: {b}"));
496        }
497        if bin.is_none() {
498            ctx.plan_action(
499                "shrink-backup not on PATH; download from https://github.com/UnconnectedBedna/shrink-backup",
500            );
501        }
502        ctx.plan_action(format!("shrink-backup -a {}", opts.dest_img.display()));
503        return Ok(opts.dest_img.clone());
504    }
505    if probe.looks_like_emmc_root {
506        return Err(HortoError::msg(
507            "shrink-backup blocked: root looks like eMMC; boot from SD/USB first",
508        ));
509    }
510    if !probe.looks_like_removable_root && !opts.force {
511        return Err(HortoError::msg(
512            "shrink-backup blocked: root not clearly SD/USB (pass --force after verifying)",
513        ));
514    }
515    let Some(bin) = bin else {
516        return Err(HortoError::msg(
517            "shrink-backup not found on PATH; install the script first (see BACKUP_YOUR_HORTO-OS.md)",
518        ));
519    };
520    if let Some(parent) = opts.dest_img.parent() {
521        fs::ensure_dir(ctx, parent)?;
522    }
523    ctx.log(format!(
524        "Running {} -a {}",
525        bin.display(),
526        opts.dest_img.display()
527    ));
528    let status = Command::new(&bin)
529        .args(["-a", &opts.dest_img.display().to_string()])
530        .status()
531        .map_err(|e| HortoError::msg(format!("shrink-backup: {e}")))?;
532    if !status.success() {
533        return Err(HortoError::msg("shrink-backup failed"));
534    }
535    Ok(opts.dest_img.clone())
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::context::{ApplyMode, HostContext};
542    use crate::pipeline::SetupKind;
543    use tempfile::tempdir;
544
545    #[test]
546    fn skip_control_env_files() {
547        assert!(should_skip_control("my_variables.env"));
548        assert!(should_skip_control("minimal_setup_vars.env"));
549        assert!(should_skip_control("iot-lan_conf.env"));
550        assert!(should_skip_control("os-configuration.env"));
551        assert!(!should_skip_control("dnsmasq.conf"));
552    }
553
554    #[test]
555    fn emmc_root_never_safe() {
556        let opts = DiskBackupOpts {
557            source: PathBuf::from("/dev/null"),
558            dest_dir: PathBuf::from("/tmp"),
559            include_boot_sectors: false,
560            force: true,
561        };
562        // Force cannot override emmc detection in probe_disk_backup logic when root is mmcblk0;
563        // unit-test the helpers directly.
564        assert!(looks_like_emmc_root("/dev/mmcblk0p1"));
565        assert!(looks_like_removable_root("/dev/mmcblk1p1"));
566        assert!(looks_like_removable_root("/dev/sda1"));
567        let _ = opts;
568    }
569
570    #[test]
571    fn timestamped_backup_dry_run() {
572        let dir = tempdir().unwrap();
573        let paths = crate::paths::HostPaths {
574            active_setup: dir.path().join("active"),
575            backup: dir.path().join("backup"),
576            docker: dir.path().join("docker"),
577            etc: dir.path().join("etc"),
578            lease_file: dir.path().join("leases"),
579        };
580        stdfs::create_dir_all(paths.etc.join("netplan")).unwrap();
581        stdfs::write(paths.etc.join("hostname"), b"horto-test\n").unwrap();
582        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
583        let report = backup_etc_timestamped(&mut ctx).unwrap();
584        assert!(report.dest.contains("backup/etc/"));
585        assert_ne!(ctx.planned.as_slice(), &[]);
586    }
587
588    #[test]
589    fn backup_disk_dry_run_and_blocked_apply() {
590        let dir = tempdir().unwrap();
591        let dest = dir.path().join("out");
592        stdfs::create_dir_all(&dest).unwrap();
593        let opts = DiskBackupOpts {
594            source: PathBuf::from("/dev/null"),
595            dest_dir: dest.clone(),
596            include_boot_sectors: true,
597            force: true,
598        };
599        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
600        let path = backup_disk(&mut ctx, &opts).unwrap();
601        assert!(path.starts_with(&dest));
602        assert!(ctx.planned.iter().any(|p| p.summary.contains("partclone")));
603
604        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
605        let err = backup_disk(&mut ctx, &opts).unwrap_err();
606        assert!(err.to_string().contains("blocked") || err.to_string().contains("cancel"));
607    }
608
609    #[test]
610    fn backup_shrink_dry_run() {
611        let dir = tempdir().unwrap();
612        let dest = dir.path().join("img.img");
613        let opts = ShrinkBackupOpts {
614            dest_img: dest.clone(),
615            force: true,
616        };
617        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
618        let out = backup_shrink(&mut ctx, &opts).unwrap();
619        assert_eq!(out, dest);
620        assert!(ctx
621            .planned
622            .iter()
623            .any(|p| p.summary.contains("shrink-backup")));
624    }
625
626    #[test]
627    fn root_mount_source_nonempty() {
628        assert_ne!(root_mount_source(), "");
629    }
630
631    fn temp_paths(root: &Path) -> crate::paths::HostPaths {
632        crate::paths::HostPaths {
633            active_setup: root.join("active_setup"),
634            backup: root.join("backup"),
635            docker: root.join("docker"),
636            etc: root.join("etc"),
637            lease_file: root.join("leases"),
638        }
639    }
640
641    #[test]
642    fn should_skip_control_all_variants() {
643        assert!(should_skip_control("my_wifi.env"));
644        assert!(should_skip_control("my_something.env"));
645        // Only .env is skipped for the my_ prefix.
646        assert!(!should_skip_control("my_notes.txt"));
647        assert!(!should_skip_control("hostname"));
648        assert!(!should_skip_control("netplan"));
649    }
650
651    #[test]
652    fn root_source_helpers_recognize_devices() {
653        assert!(looks_like_emmc_root("/dev/mmcblk0p1"));
654        assert!(!looks_like_emmc_root("/dev/sda1"));
655        assert!(looks_like_removable_root("/dev/mmcblk1p1"));
656        assert!(looks_like_removable_root("/dev/sdb2"));
657        assert!(looks_like_removable_root("usb-XYZ"));
658        assert!(!looks_like_removable_root("/dev/mmcblk0p1"));
659    }
660
661    #[test]
662    fn copy_managed_etc_reports_copied_missing_and_control() {
663        let dir = tempdir().unwrap();
664        let paths = temp_paths(dir.path());
665        // Seed /etc with some managed entries.
666        stdfs::create_dir_all(paths.etc.join("netplan")).unwrap();
667        stdfs::write(paths.etc.join("netplan/99-iot-lan.yaml"), b"network: {}\n").unwrap();
668        stdfs::write(paths.etc.join("hostname"), b"cov-box\n").unwrap();
669        stdfs::write(paths.etc.join("hosts"), b"127.0.0.1 localhost\n").unwrap();
670        // Intentionally omit `resolv.conf` so it lands in skipped_missing.
671        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
672        let dest = dir.path().join("backup-dest");
673        let report = copy_managed_etc(&mut ctx, &dest).unwrap();
674        assert!(report.copied.iter().any(|c| c == "netplan"));
675        assert!(report.copied.iter().any(|c| c == "hostname"));
676        assert!(matches!(report.skipped_control.as_slice(), [_, ..]));
677        assert!(report
678            .skipped_missing
679            .iter()
680            .any(|s| s == "resolv.conf" || s == "sysctl.d" || !s.is_empty()));
681        assert!(dest.join("hostname").is_file());
682        assert!(dest.join("netplan/99-iot-lan.yaml").is_file());
683    }
684
685    #[test]
686    fn copy_managed_etc_dry_run_records_plans() {
687        let dir = tempdir().unwrap();
688        let paths = temp_paths(dir.path());
689        stdfs::create_dir_all(paths.etc.join("netplan")).unwrap();
690        stdfs::write(paths.etc.join("hostname"), b"box\n").unwrap();
691        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
692        let dest = dir.path().join("backup-dest");
693        let report = copy_managed_etc(&mut ctx, &dest).unwrap();
694        assert_ne!(ctx.planned.as_slice(), &[]);
695        assert!(report.copied.iter().any(|c| c == "hostname"));
696    }
697
698    #[test]
699    fn backup_etc_initial_writes_to_initial_setup() {
700        let dir = tempdir().unwrap();
701        let paths = temp_paths(dir.path());
702        stdfs::create_dir_all(&paths.etc).unwrap();
703        stdfs::write(paths.etc.join("hostname"), b"box\n").unwrap();
704        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
705        let report = backup_etc_initial(&mut ctx).unwrap();
706        assert!(report.dest.contains("initial_setup"));
707        assert!(ctx.paths.initial_backup_etc().join("hostname").is_file());
708    }
709
710    #[test]
711    fn list_timestamped_hides_initial_setup() {
712        let dir = tempdir().unwrap();
713        let paths = temp_paths(dir.path());
714        let etc_root = paths.backup.join("etc");
715        stdfs::create_dir_all(etc_root.join("initial_setup")).unwrap();
716        stdfs::create_dir_all(etc_root.join("20240101-000000")).unwrap();
717        stdfs::create_dir_all(etc_root.join("20241231-120000")).unwrap();
718        let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
719        let listed = list_timestamped_etc_backups(&ctx);
720        assert_eq!(listed, vec!["20240101-000000", "20241231-120000"]);
721        assert!(!listed.iter().any(|n| n == "initial_setup"));
722    }
723
724    #[test]
725    fn list_timestamped_returns_empty_when_missing() {
726        let dir = tempdir().unwrap();
727        let paths = temp_paths(dir.path());
728        let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
729        assert_eq!(list_timestamped_etc_backups(&ctx), Vec::<String>::new());
730    }
731
732    #[test]
733    fn backup_status_aggregates_probe_and_dirs() {
734        let dir = tempdir().unwrap();
735        let paths = temp_paths(dir.path());
736        stdfs::create_dir_all(paths.backup.join("etc/initial_setup")).unwrap();
737        stdfs::create_dir_all(paths.backup.join("etc/20240101-010101")).unwrap();
738        let ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full).with_paths(paths);
739        let status = backup_status(&ctx);
740        assert!(status.initial_setup_present);
741        assert!(status.timestamped.iter().any(|n| n == "20240101-010101"));
742        // Probe is always populated with notes on a sane host.
743        assert!(matches!(status.disk.notes.as_slice(), [_, ..]));
744    }
745
746    #[test]
747    fn probe_missing_source_and_dest_reports_blockers() {
748        let opts = DiskBackupOpts {
749            source: PathBuf::from("/definitely/missing/device"),
750            dest_dir: PathBuf::from("/definitely/missing/dir"),
751            include_boot_sectors: false,
752            force: false,
753        };
754        let probe = probe_disk_backup(&opts);
755        assert!(!probe.source_exists);
756        assert!(!probe.dest_dir_exists);
757        assert!(probe.blockers.iter().any(|b| b.contains("Source device")));
758        assert!(probe.blockers.iter().any(|b| b.contains("Destination")));
759        assert!(!probe.safe_to_apply);
760    }
761
762    #[test]
763    fn probe_force_flag_does_not_bypass_emmc_root_check() {
764        // Real root of the CI/dev host is not eMMC; force still cannot unsafe-apply
765        // when tools are missing. Verify the safe_to_apply is a strict AND.
766        let dir = tempdir().unwrap();
767        let opts = DiskBackupOpts {
768            source: PathBuf::from("/dev/null"),
769            dest_dir: dir.path().to_path_buf(),
770            include_boot_sectors: false,
771            force: true,
772        };
773        let probe = probe_disk_backup(&opts);
774        // With force, safe_to_apply depends on presence of partclone/gzip on the host.
775        let partclone = probe.partclone_present;
776        let gzip = probe.gzip_present;
777        let source = probe.source_exists;
778        let dest = probe.dest_dir_exists;
779        let not_emmc = !probe.looks_like_emmc_root;
780        let expected = partclone && gzip && source && dest && not_emmc;
781        assert_eq!(probe.safe_to_apply, expected);
782    }
783
784    #[test]
785    fn plan_disk_backup_records_boot_sector_action_when_requested() {
786        let dir = tempdir().unwrap();
787        let dest = dir.path().join("out");
788        stdfs::create_dir_all(&dest).unwrap();
789        let opts = DiskBackupOpts {
790            source: PathBuf::from("/dev/null"),
791            dest_dir: dest,
792            include_boot_sectors: true,
793            force: true,
794        };
795        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
796        let planned = plan_disk_backup(&mut ctx, &opts);
797        assert!(planned.iter().any(|p| p.summary.contains("partclone")));
798        assert!(planned
799            .iter()
800            .any(|p| p.summary.contains("dd if=/dev/mmcblk0") && p.summary.contains("bs=1M")));
801    }
802
803    #[test]
804    fn plan_disk_backup_without_boot_sectors_skips_dd() {
805        let dir = tempdir().unwrap();
806        let dest = dir.path().join("out");
807        stdfs::create_dir_all(&dest).unwrap();
808        let opts = DiskBackupOpts {
809            source: PathBuf::from("/dev/null"),
810            dest_dir: dest,
811            include_boot_sectors: false,
812            force: false,
813        };
814        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
815        let planned = plan_disk_backup(&mut ctx, &opts);
816        assert!(planned.iter().any(|p| p.summary.contains("partclone")));
817        assert!(!planned
818            .iter()
819            .any(|p| p.summary.contains("dd if=/dev/mmcblk0")));
820    }
821
822    #[test]
823    fn backup_shrink_dry_run_lists_download_hint_when_missing() {
824        let dir = tempdir().unwrap();
825        let dest = dir.path().join("img.img.gz");
826        let opts = ShrinkBackupOpts {
827            dest_img: dest.clone(),
828            force: false,
829        };
830        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
831        let out = backup_shrink(&mut ctx, &opts).unwrap();
832        assert_eq!(out, dest);
833        assert_ne!(ctx.planned.as_slice(), &[]);
834        assert!(
835            ctx.planned
836                .iter()
837                .any(|p| p.summary.contains("shrink-backup")),
838            "expected a shrink-backup plan entry: {:?}",
839            ctx.planned
840        );
841    }
842
843    #[test]
844    fn backup_shrink_apply_without_force_or_bin_errors() {
845        let dir = tempdir().unwrap();
846        let dest = dir.path().join("img.img.gz");
847        let opts = ShrinkBackupOpts {
848            dest_img: dest,
849            force: false,
850        };
851        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
852        // On the dev host root is not eMMC and not "clearly removable", so with force=false
853        // apply refuses regardless of shrink-backup presence.
854        let err = backup_shrink(&mut ctx, &opts).unwrap_err();
855        let msg = err.to_string();
856        assert!(msg.contains("shrink-backup blocked") || msg.contains("not found"));
857    }
858
859    #[test]
860    fn backup_disk_apply_blocked_when_dest_missing_and_no_force() {
861        let dir = tempdir().unwrap();
862        let opts = DiskBackupOpts {
863            source: PathBuf::from("/dev/null"),
864            dest_dir: dir.path().join("nope"),
865            include_boot_sectors: false,
866            force: false,
867        };
868        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
869        let err = backup_disk(&mut ctx, &opts).unwrap_err();
870        assert!(err.to_string().contains("blocked"));
871    }
872
873    #[test]
874    fn probe_notes_call_out_removable_expectation() {
875        let opts = DiskBackupOpts::default();
876        let probe = probe_disk_backup(&opts);
877        assert!(probe.notes.iter().any(|n| n.contains("SD/USB")));
878    }
879}