diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..8417aa2 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,485 @@ +//! Copies of what a run is about to overwrite (PLAN.md §5.3). +//! +//! ## Scoped by what cannot be fetched again +//! +//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are +//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema +//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state +//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not +//! free: it would bury the two things that matter. +//! +//! What a run can destroy irrecoverably is short: +//! +//! 1. **The operator's own edits to a file the overlay owns.** `Bridge.cfg` is deliberately kept +//! (PLAN.md §5 Phase 1), but every `.cs` file and `Scripts.csproj` is overwritten +//! *unconditionally and by design* — so the one place this tool knowingly discards work is the +//! one place it should keep a copy first. +//! 2. **A stock ServUO file the patch tier edits.** `patches/originals/` already holds the +//! pre-*tier* copy and is never overwritten, which is the right revert target; it is not a +//! record of what the file looked like *this morning*, after the operator's own later edits. +//! 3. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's +//! saved configuration starts answering `401` with nothing on the sidecar to explain why. +//! +//! ## What decides whether a backup happens +//! +//! **Whether this run is about to overwrite something** — not which verb was typed and not whether +//! a prior record exists. PLAN.md §5.3 framed it as "`update`, and `install` over an existing +//! record", on the reasoning that a first install overwrites nothing. That reasoning does not +//! survive contact with `INSTALL.md` Appendix A2, which documents deploying the overlay **by hand**: +//! a first `install` over such a tree finds `.cs` files that differ, plans them as `Change`, and +//! overwrites them with no record anywhere of what was there. So the test is the direct one, and a +//! genuine first install onto a clean tree still writes nothing because there is nothing to copy. +//! +//! ## Restoring is printed, not done +//! +//! Same rule as the uninstall report, and for the same reason: the installer cannot know what has +//! changed since, and a restore that puts an old `.cs` file back over a newer overlay eats work +//! rather than saving it. The path and the manifest are what this module hands over. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::paths::Layout; +use crate::util; + +/// How many backup directories survive. Older ones are pruned as new ones are written. +/// +/// An unbounded directory of ServUO source copies on a shard host is its own support problem, and +/// the value of an old backup falls off a cliff: what an operator reaches for is "before this +/// upgrade", occasionally "before the one before". Three is that, plus one. +pub const KEEP: usize = 3; + +/// The `schema` written into `manifest.json`, so a future reader can tell shapes apart. +const SCHEMA: u32 = 1; + +/// Why a file was copied. Recorded per entry, because "what did this upgrade touch" is answered +/// very differently by an overlay file and by a stock ServUO file the patch tier edited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reason { + /// An overlay file whose on-disk content the sync is about to replace. + OverlayChange, + /// A stock ServUO file the patch tier is about to edit. + PatchTarget, + /// A companion `.cs` the tier copies in, which already existed in the tree. + PatchCompanion, + /// `sidecar.toml` — the token the website holds. + SidecarConfig, +} + +impl Reason { + fn as_str(self) -> &'static str { + match self { + Self::OverlayChange => "overlay-change", + Self::PatchTarget => "patch-target", + Self::PatchCompanion => "patch-companion", + Self::SidecarConfig => "sidecar-config", + } + } + + /// Which sub-directory of the backup the copy lands under. + /// + /// The two roots are kept apart because a path is only meaningful relative to one of them, and + /// without the split a state file could collide with a tree file of the same name. + fn root_dir(self) -> &'static str { + match self { + Self::SidecarConfig => "state", + _ => "servuo", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Manifest { + pub schema: u32, + /// RFC 3339, when the backup was taken. + pub taken: String, + /// `install` or `update` — the verb that displaced these files. + pub command: String, + pub installer: String, + /// The bundle in the record before this run, when there was one. + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_from: Option, + /// The bundle this run is moving to. + pub bundle_to: String, + pub servuo_root: String, + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Entry { + /// Where the copy sits inside the backup directory, `/`-separated. + pub path: String, + /// Where it was copied from, absolute, as it was on this host. + pub source: String, + pub sha256: String, + pub reason: String, +} + +/// One run's backup. Created up front and handed to each stage that writes. +/// +/// **The directory is created lazily, on the first capture.** A run that overwrites nothing must +/// leave nothing behind — an empty dated directory per run would be indistinguishable from a +/// backup that failed to record anything, and would push real ones out of the retention window. +pub struct Session { + dir: PathBuf, + enabled: bool, + started: bool, + root: PathBuf, + layout: Layout, + command: &'static str, + bundle_from: Option, + bundle_to: String, + taken: String, + entries: Vec, +} + +impl Session { + /// `enabled` is false for `--verify` (a dry run must not create state, the same rule that keeps + /// it away from `--print-config`) and for `--no-backup`. + pub fn new( + layout: &Layout, + root: &Path, + command: &'static str, + bundle_from: Option, + bundle_to: String, + enabled: bool, + ) -> Self { + let taken = chrono::Utc::now(); + Self { + // Colons are not legal in a Windows path component, so the stamp is the basic ISO 8601 + // form. It still sorts lexicographically, which is what the pruning relies on. + dir: layout + .backups_dir() + .join(taken.format("%Y%m%dT%H%M%SZ").to_string()), + enabled, + started: false, + root: root.to_path_buf(), + layout: layout.clone(), + command, + bundle_from, + bundle_to, + taken: taken.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + entries: Vec::new(), + } + } + + /// True once something has actually been copied. + pub fn has_entries(&self) -> bool { + !self.entries.is_empty() + } + + /// Copies `source` into this backup, if it exists and backups are enabled. + /// + /// A file that does not exist is not an error and not an entry: the caller asks for anything it + /// *may* be about to overwrite, and "there was nothing there" is the common answer on a first + /// install. + pub fn capture(&mut self, source: &Path, reason: Reason) -> Result<()> { + if !self.enabled || !source.exists() { + return Ok(()); + } + + let rel = self.relative_to_root(source, reason); + let dest = self.dir.join(reason.root_dir()).join(&rel); + if dest.exists() { + // Two stages can name the same file — a patch target that is also a companion path, or + // a re-entrant caller. First copy wins: it is the one taken furthest from any write. + return Ok(()); + } + + if !self.started { + fs::create_dir_all(&self.dir).with_context(|| { + format!("cannot create the backup directory {}", self.dir.display()) + })?; + self.started = true; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + + fs::copy(source, &dest).with_context(|| { + format!( + "cannot back up {} before overwriting it. Re-run with --no-backup to proceed \ + without a copy", + source.display() + ) + })?; + + self.entries.push(Entry { + path: format!( + "{}/{}", + reason.root_dir(), + rel.replace(std::path::MAIN_SEPARATOR, "/") + ), + source: source.display().to_string(), + sha256: util::sha256_file(source)?, + reason: reason.as_str().to_string(), + }); + Ok(()) + } + + /// Writes `manifest.json` and prunes older backups. Returns the directory when one was written. + /// + /// The manifest is written **last**, so a directory carrying one is a complete backup. Pruning + /// only considers directories that have one, for the same reason: a run interrupted mid-copy + /// must not be able to evict a good backup by being newer than it. + pub fn finish(mut self) -> Result> { + if !self.started { + return Ok(None); + } + + self.entries.sort_by(|a, b| a.path.cmp(&b.path)); + let manifest = Manifest { + schema: SCHEMA, + taken: self.taken.clone(), + command: self.command.to_string(), + installer: env!("CARGO_PKG_VERSION").to_string(), + bundle_from: self.bundle_from.clone(), + bundle_to: self.bundle_to.clone(), + servuo_root: self.root.display().to_string(), + files: self.entries.clone(), + }; + let body = serde_json::to_string_pretty(&manifest)? + "\n"; + util::write_atomic(&manifest_path(&self.dir), body.as_bytes()) + .context("cannot write the backup manifest")?; + + prune(&self.layout, KEEP)?; + Ok(Some(self.dir.clone())) + } + + /// The path a captured file takes inside the backup, relative to the root it belongs to. + fn relative_to_root(&self, source: &Path, reason: Reason) -> String { + let rel = match reason.root_dir() { + "servuo" => source.strip_prefix(&self.root).unwrap_or(source), + _ => source + .strip_prefix(&self.layout.state_dir) + .unwrap_or(source), + }; + // An absolute path outside the root it was filed under would escape the backup directory + // when joined. Falling back to the file name keeps the copy inside; the manifest still + // records exactly where it came from. + if rel.is_absolute() || rel.as_os_str().is_empty() { + return source + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".to_string()); + } + rel.to_string_lossy().to_string() + } +} + +fn manifest_path(dir: &Path) -> PathBuf { + dir.join("manifest.json") +} + +/// Every complete backup on this host, newest first. +pub fn list(layout: &Layout) -> Vec { + let mut dirs: Vec = match fs::read_dir(layout.backups_dir()) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| manifest_path(p).is_file()) + .collect(), + Err(_) => Vec::new(), + }; + // The stamp is fixed-width and zero-padded, so lexicographic order is chronological order. + dirs.sort(); + dirs.reverse(); + dirs +} + +/// Reads one backup's manifest. +pub fn read_manifest(dir: &Path) -> Result { + let body = fs::read_to_string(manifest_path(dir)) + .with_context(|| format!("cannot read {}", manifest_path(dir).display()))?; + serde_json::from_str(&body) + .with_context(|| format!("{} is not a backup manifest", manifest_path(dir).display())) +} + +/// Removes all but the `keep` newest complete backups. +pub fn prune(layout: &Layout, keep: usize) -> Result<()> { + for old in list(layout).into_iter().skip(keep) { + fs::remove_dir_all(&old) + .with_context(|| format!("cannot remove the old backup {}", old.display()))?; + } + Ok(()) +} + +/// Deletes every backup. Reached only from `uninstall --purge`, alongside the config, the database +/// and the cached patch set — they are all the same kind of thing: the only offline record of what +/// was here before. +pub fn remove_all(layout: &Layout) -> Result<()> { + let dir = layout.backups_dir(); + if dir.exists() { + fs::remove_dir_all(&dir).with_context(|| format!("cannot remove {}", dir.display()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + fn layout_in(root: &Path) -> Layout { + let mut layout = crate::paths::layout(); + layout.state_dir = root.join("state"); + layout + } + + fn write(path: &Path, body: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); + } + + #[test] + fn a_run_that_overwrites_nothing_leaves_nothing_behind() { + let tmp = TempDir::new("backup-empty").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true); + // Nothing on disk to copy — the common first-install case. + session + .capture( + &root.join("Scripts/Custom/Bridge/BridgeLink.cs"), + Reason::OverlayChange, + ) + .unwrap(); + assert!(session.finish().unwrap().is_none()); + assert!( + !layout.backups_dir().exists(), + "an empty dated directory would be indistinguishable from a failed backup" + ); + } + + #[test] + fn a_captured_file_is_copied_verbatim_and_recorded() { + let tmp = TempDir::new("backup-capture").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs"); + write(&source, "the operator's own edit\n"); + write(&layout.sidecar_config(), "[web]\nauth_token = \"secret\"\n"); + + let mut session = Session::new( + &layout, + &root, + "update", + Some("2026.08.04".into()), + "2026.08.05".into(), + true, + ); + session.capture(&source, Reason::OverlayChange).unwrap(); + session + .capture(&layout.sidecar_config(), Reason::SidecarConfig) + .unwrap(); + let dir = session.finish().unwrap().expect("a backup was taken"); + + let copy = dir.join("servuo/Scripts/Custom/Bridge/BridgeLink.cs"); + assert_eq!( + fs::read_to_string(©).unwrap(), + "the operator's own edit\n" + ); + assert!(dir.join("state/sidecar.toml").is_file()); + + let manifest = read_manifest(&dir).unwrap(); + assert_eq!(manifest.command, "update"); + assert_eq!(manifest.bundle_from.as_deref(), Some("2026.08.04")); + assert_eq!(manifest.files.len(), 2); + let overlay = manifest + .files + .iter() + .find(|f| f.reason == "overlay-change") + .unwrap(); + // The path inside the backup is always `/`-separated, so a manifest written on Windows + // reads the same as one written on Linux. + assert_eq!(overlay.path, "servuo/Scripts/Custom/Bridge/BridgeLink.cs"); + assert_eq!(overlay.sha256, util::sha256_file(&source).unwrap()); + assert!(overlay.source.contains("BridgeLink.cs")); + } + + #[test] + fn the_first_copy_of_a_file_wins() { + // Two stages can name the same path. The earlier capture is the one taken furthest from + // any write, so a later one must not overwrite it with content that has already changed. + let tmp = TempDir::new("backup-twice").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let source = root.join("Server/EventSink.cs"); + write(&source, "before\n"); + + let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true); + session.capture(&source, Reason::PatchTarget).unwrap(); + write(&source, "after\n"); + session.capture(&source, Reason::PatchTarget).unwrap(); + let dir = session.finish().unwrap().unwrap(); + + assert_eq!( + fs::read_to_string(dir.join("servuo/Server/EventSink.cs")).unwrap(), + "before\n" + ); + assert_eq!(read_manifest(&dir).unwrap().files.len(), 1); + } + + #[test] + fn disabled_sessions_write_nothing() { + let tmp = TempDir::new("backup-off").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs"); + write(&source, "content\n"); + + let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), false); + session.capture(&source, Reason::OverlayChange).unwrap(); + assert!(session.finish().unwrap().is_none()); + assert!(!layout.backups_dir().exists()); + } + + #[test] + fn pruning_keeps_the_newest_and_ignores_incomplete_directories() { + let tmp = TempDir::new("backup-prune").unwrap(); + let layout = layout_in(tmp.path()); + for stamp in ["20260801T000000Z", "20260802T000000Z", "20260803T000000Z"] { + write( + &layout.backups_dir().join(stamp).join("manifest.json"), + "{\"schema\":1}", + ); + } + // A run interrupted before its manifest was written. It must neither be listed nor be able + // to evict a complete backup by being newer. + write( + &layout.backups_dir().join("20260804T000000Z/servuo/x.cs"), + "half a copy\n", + ); + + assert_eq!(list(&layout).len(), 3); + prune(&layout, 2).unwrap(); + + let kept: Vec = list(&layout) + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert_eq!(kept, vec!["20260803T000000Z", "20260802T000000Z"]); + assert!( + layout.backups_dir().join("20260804T000000Z").exists(), + "an incomplete directory is left for a human to look at, not silently deleted" + ); + } + + #[test] + fn purge_removes_every_backup() { + let tmp = TempDir::new("backup-purge").unwrap(); + let layout = layout_in(tmp.path()); + write( + &layout.backups_dir().join("20260801T000000Z/manifest.json"), + "{\"schema\":1}", + ); + remove_all(&layout).unwrap(); + assert!(!layout.backups_dir().exists()); + // Removing what is not there is not an error: `uninstall --purge` runs on hosts that never + // took a backup. + remove_all(&layout).unwrap(); + } +} diff --git a/src/cli.rs b/src/cli.rs index dac8608..518e39c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -81,8 +81,11 @@ pub struct Cli { pub site_url: Option, /// `--yes`: assume the default answer to every prompt. pub assume_yes: bool, - /// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`. + /// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set + /// and every backup. pub purge: bool, + /// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3). + pub no_backup: bool, } impl Default for Cli { @@ -98,6 +101,7 @@ impl Default for Cli { site_url: None, assume_yes: false, purge: false, + no_backup: false, } } } @@ -138,8 +142,13 @@ Options: On uninstall it means yes: that prompt defaults to no, and typing `uninstall --yes` is not an accident. - --purge uninstall. Also delete sidecar.toml and - uo-link.db, which are otherwise kept. + --no-backup install, update. Do not copy the files + this run is about to overwrite. They are + otherwise saved under /backups/, + newest 3 kept. + --purge uninstall. Also delete sidecar.toml, + uo-link.db, the cached patch set and every + backup, all of which are otherwise kept. -V, --version Print the installer version and exit. -h, --help Print this help and exit. @@ -182,6 +191,7 @@ pub fn parse>(args: I) -> Result { "--verify" => cli.verify = true, "--yes" | "-y" => cli.assume_yes = true, "--purge" => cli.purge = true, + "--no-backup" => cli.no_backup = true, "--patches" => cli.patches = PatchChoice::Yes, "--no-patches" => cli.patches = PatchChoice::No, "--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true, diff --git a/src/doctor.rs b/src/doctor.rs index 5500765..fc7861a 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -158,6 +158,9 @@ pub fn run(cli: &Cli) -> Result { // ── The bundle ─────────────────────────────────────────────────────────── rows.push(bundle_row(&record)); + // ── Backups ────────────────────────────────────────────────────────────── + rows.push(backup_row(&layout)); + // ── Report ─────────────────────────────────────────────────────────────── println!(); for row in &rows { @@ -673,6 +676,35 @@ fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>) /// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way /// to run this — the operator downloads artifacts elsewhere — and failing a health check over it /// would report a working deployment as broken. +/// The most recent backup, so "can I go back?" is answerable without knowing the layout. +/// +/// Always `✓`, never a failure: having no backup is the correct state on a host that has never +/// overwritten anything, and a shard that is running fine does not become broken because nothing +/// has displaced a file yet. +fn backup_row(layout: &paths::Layout) -> Row { + let backups = crate::backup::list(layout); + let Some(newest) = backups.first() else { + return Row::ok("Backups", "none taken — no run has replaced a file yet"); + }; + let detail = match crate::backup::read_manifest(newest) { + Ok(manifest) => format!( + "{} — {} file(s) replaced by {} to bundle {}", + manifest.taken, + manifest.files.len(), + manifest.command, + manifest.bundle_to + ), + // A directory with an unreadable manifest is still a directory of the operator's files, so + // it is reported rather than skipped. + Err(_) => format!("{} — manifest unreadable", newest.display()), + }; + Row::ok("Backups", detail).note(format!( + "{} kept in {}", + backups.len(), + layout.backups_dir().display() + )) +} + fn bundle_row(record: &InstallRecord) -> Row { let url = bundle::url_for(None); let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) { diff --git a/src/install.rs b/src/install.rs index 017ff0b..254f746 100644 --- a/src/install.rs +++ b/src/install.rs @@ -38,7 +38,7 @@ use crate::record::{ }; use crate::servuo::ServUoRoot; use crate::util::TempDir; -use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; +use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; /// Which verb is driving the pipeline. /// @@ -185,6 +185,23 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let planned = overlay::plan(&unpacked, &root.path, prior_files)?; let summary = overlay::summarize(&planned); + // ── Backup ─────────────────────────────────────────────────────────────── + // Created before the first write and handed to every stage that overwrites, so each copy is + // taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily: + // a run that displaces nothing leaves nothing behind. + let mut backup = backup::Session::new( + &layout, + &root.path, + if mode.is_update() { + "update" + } else { + "install" + }, + prior.as_ref().map(|p| p.bundle.tag.clone()), + bundle.bundle.clone(), + !cli.verify && !cli.no_backup, + ); + ui::heading("Overlay sync"); let lines = overlay::render(&planned); if lines.is_empty() { @@ -200,6 +217,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { summary.add, summary.change, summary.unchanged, summary.kept ); } else { + // `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what + // would replace it, and `KeptOperatorModified` is not written at all — copying those three + // would bury the files that are actually being displaced. + for file in planned + .iter() + .filter(|f| f.action == overlay::Action::Change) + { + backup.capture(&file.dst, backup::Reason::OverlayChange)?; + } overlay::apply(&planned)?; // "deployed" is claimed only when something actually moved. A run that copied nothing // reporting "deployed" would read as a fresh install to anyone skimming the output. @@ -246,8 +272,18 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { .as_ref() .map(|p| p.patch_records()) .unwrap_or_default(), + &mut backup, )?; + // The config joins a backup that is already being taken; it is never the reason for one. The + // installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that + // a restored set of files comes with the token that matches them, rather than an operator + // restoring a tree and then finding the website pointed at a token that has moved on. + if backup.has_entries() { + backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?; + } + let backup_dir = backup.finish()?; + // ── The sidecar and its service ────────────────────────────────────────── let sidecar = install_sidecar( cli, @@ -294,6 +330,19 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { } } + // Named after the record rather than at the moment it was taken, because that is where an + // operator looks when a run has finished and something is wrong. Restoring is theirs to do: + // the installer cannot know what has changed since, and putting an old `.cs` file back over a + // newer overlay eats work rather than saving it. + if let Some(dir) = &backup_dir { + println!("\n Backed up {}", dir.display()); + println!( + " the files this run replaced, with a manifest naming each one.\n\ + \x20 The newest {} backups are kept; `uninstall --purge` removes them.", + backup::KEEP + ); + } + // ── Closing notes ──────────────────────────────────────────────────────── println!(); if cli.verify { diff --git a/src/lib.rs b/src/lib.rs index 0f41f3a..a832142 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ //! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering //! name. Nothing an operator sees changes. +pub mod backup; pub mod bundle; pub mod cli; pub mod diff; diff --git a/src/paths.rs b/src/paths.rs index dc97445..ed73b5c 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -68,6 +68,16 @@ impl Layout { self.patches_dir().join("originals") } + /// `/etc/runicgateway/backups` — one dated directory per run that overwrote something + /// (PLAN.md §5.3). + /// + /// Beside the cached patch set rather than inside it: both survive an uninstall and both go + /// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy + /// of what the *release* shipped. + pub fn backups_dir(&self) -> PathBuf { + self.state_dir.join("backups") + } + /// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated /// layout, where no service is registered at all. pub fn systemd_unit(&self) -> PathBuf { diff --git a/src/tier.rs b/src/tier.rs index b041a04..00523fa 100644 --- a/src/tier.rs +++ b/src/tier.rs @@ -95,6 +95,7 @@ pub fn run( declared: Option<&Tier>, layout: &paths::Layout, prior: &[FeatureRecord], + backup: &mut crate::backup::Session, ) -> Result { let declared_tier = Tier::resolve(declared); if declared_tier.features.is_empty() { @@ -131,7 +132,7 @@ pub fn run( ), ); announce_new_features(&declared_tier, &tier); - return apply_tier(cli, root, unpacked, &tier, layout, prior, supported); + return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup); } match consent(cli, root, supported, &tier)? { @@ -157,7 +158,7 @@ pub fn run( } } - apply_tier(cli, root, unpacked, &tier, layout, prior, supported) + apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup) } /// The subset of a release's tier that a previous run actually applied. @@ -322,6 +323,7 @@ fn apply_tier( layout: &paths::Layout, prior: &[FeatureRecord], supported: bool, + backup: &mut crate::backup::Session, ) -> Result { let previous = patch::index_records(prior); let mut records: Vec = Vec::new(); @@ -353,7 +355,7 @@ fn apply_tier( } if !cli.verify { - write_feature(root, unpacked, feature, &resolved, layout)?; + write_feature(root, unpacked, feature, &resolved, layout, backup)?; } applied_patches += resolved.len(); @@ -521,9 +523,18 @@ fn write_feature( feature: &Feature, resolved: &[Resolved], layout: &paths::Layout, + backup: &mut crate::backup::Session, ) -> Result<()> { for r in resolved { if let Resolution::Applicable { edits, .. } = &r.resolution { + // `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the + // right thing to revert to. It is not a copy of what this file looked like before *this* + // run, though — on a second tier pass the operator's own later edits are only in the + // backup (PLAN.md §5.3). + backup.capture( + &patch::join(&root.path, &r.target), + crate::backup::Reason::PatchTarget, + )?; let original = patch::join(&layout.patch_originals_dir(), &r.target); if !original.exists() { write_atomic(&original, &r.content) @@ -542,6 +553,9 @@ fn write_feature( for companion in &feature.companions { let src = patch::join(unpacked, &companion.file); let dst = patch::join(&root.path, &companion.install_to); + // Copied unconditionally, like every other `.cs` the overlay owns — so an operator who + // edited one loses it here unless a copy is taken first. + backup.capture(&dst, crate::backup::Reason::PatchCompanion)?; if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; diff --git a/src/uninstall.rs b/src/uninstall.rs index 27a33bf..e34951e 100644 --- a/src/uninstall.rs +++ b/src/uninstall.rs @@ -121,11 +121,24 @@ pub fn run(cli: &Cli) -> Result { if cli.purge { remove_dir(&layout.patches_dir(), &mut done, &mut problems); - } else if layout.patches_dir().exists() { - done.push(format!( - "kept {} — the cached patches and the pre-patch originals you need to revert by hand", - layout.patches_dir().display() - )); + remove_dir(&layout.backups_dir(), &mut done, &mut problems); + } else { + if layout.patches_dir().exists() { + done.push(format!( + "kept {} — the cached patches and the pre-patch originals you need to revert by hand", + layout.patches_dir().display() + )); + } + // Same rule and the same reason as the patch cache: a backup is the only copy of what this + // host had before an upgrade replaced it, and it outlives the deployment that took it. + let backups = crate::backup::list(&layout); + if !backups.is_empty() { + done.push(format!( + "kept {} — {} backup(s) of files earlier runs replaced", + layout.backups_dir().display(), + backups.len() + )); + } } remove_file(&record_path, &mut done, &mut problems); @@ -181,6 +194,7 @@ fn print_intent( println!(" · {}", layout.install_record().display()); if purge { println!(" · {} [--purge]", layout.patches_dir().display()); + println!(" · {} [--purge]", layout.backups_dir().display()); } println!(); @@ -199,6 +213,14 @@ fn print_intent( " · {} (cached patches and pre-patch originals)", layout.patches_dir().display() ); + let backups = crate::backup::list(layout); + if !backups.is_empty() { + println!( + " · {} ({} backup(s) of files earlier runs replaced)", + layout.backups_dir().display(), + backups.len() + ); + } } let _ = record; println!(); @@ -241,6 +263,7 @@ fn render_report( render_overlay_section(&mut out, record); render_patch_section(&mut out, record, layout, purge); + render_backup_section(&mut out, layout, purge); let _ = writeln!( out, @@ -373,6 +396,50 @@ fn render_patch_section( } } +/// The backups earlier runs took, since this report is the durable record of what was left behind. +/// +/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by +/// the time this report is read the run that took it is long out of the scrollback. +fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) { + let backups = crate::backup::list(layout); + if backups.is_empty() { + return; + } + if purge { + let _ = writeln!( + out, + " +── Backups ────────────────────────────────────────────────────────────────── + + {} backup(s) of files earlier runs replaced were removed by --purge. +", + backups.len() + ); + return; + } + let _ = writeln!( + out, + " +── Backups ────────────────────────────────────────────────────────────────── + + Copies of the files earlier runs replaced, newest first. These are kept: +" + ); + for dir in &backups { + let count = crate::backup::read_manifest(dir) + .map(|m| m.files.len()) + .unwrap_or(0); + let _ = writeln!(out, " {} ({} file(s))", dir.display(), count); + } + let _ = writeln!( + out, + " + Each carries a manifest.json naming where every file came from. Restoring is yours to + do — this tool will not put an old file back over a newer one. `--purge` removes them. +" + ); +} + /// Renders one cached patch's added and removed lines, indented for the report. fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option { let path = layout.patches_dir().join(format!("{name}.patch"));