1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct EtcBackupReport {
21 pub dest: String,
23 pub copied: Vec<String>,
25 pub skipped_missing: Vec<String>,
27 pub skipped_control: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BackupStatus {
34 pub initial_setup_present: bool,
36 pub timestamped: Vec<String>,
38 pub disk: DiskBackupProbe,
40}
41
42#[derive(Debug, Clone)]
44pub struct DiskBackupOpts {
45 pub source: PathBuf,
47 pub dest_dir: PathBuf,
49 pub include_boot_sectors: bool,
51 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#[allow(clippy::struct_excessive_bools)]
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DiskBackupFlags {
71 pub looks_like_emmc_root: bool,
73 pub looks_like_removable_root: bool,
75 pub partclone_present: bool,
77 pub gzip_present: bool,
79 pub source_exists: bool,
81 pub dest_dir_exists: bool,
83 pub safe_to_apply: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DiskBackupProbe {
90 pub root_source: String,
92 #[serde(flatten)]
94 pub flags: DiskBackupFlags,
95 pub blockers: Vec<String>,
97 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#[derive(Debug, Clone)]
111pub struct ShrinkBackupOpts {
112 pub dest_img: PathBuf,
114 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
130pub 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
181pub 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
193pub 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#[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#[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#[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#[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 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
358pub 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
383pub 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
477pub 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 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 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 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 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 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 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 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 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}