diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-11-01 07:13:45 -0600 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-11-01 07:13:45 -0600 |
| commit | 9349a2de648e241da8e00ba7ae25cb2a877ff3e9 (patch) | |
| tree | 2fea913ee0aaff4370877f4a91c911db2fe37592 /src/app/archive.rs | |
| parent | 5b89c370d5974f057306a52089f14c337565fd1d (diff) | |
feat(guix): carry prebuilt runtime and recover preserved state
Diffstat (limited to 'src/app/archive.rs')
| -rw-r--r-- | src/app/archive.rs | 263 |
1 files changed, 251 insertions, 12 deletions
diff --git a/src/app/archive.rs b/src/app/archive.rs index 2a78644..0965430 100644 --- a/src/app/archive.rs +++ b/src/app/archive.rs @@ -152,13 +152,13 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< }) .context("Caddy is not present")?; let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?; - let target = stage.join("var/data/caddy"); - fs::create_dir_all(&target)?; for name in ["data", "config"] { + let target = stage.join("var/data/caddy").join(name); + files::private_dir(&target)?; process::capture( project .command("podman")? - .args(["cp", &format!("{id}:/{name}")]) + .args(["cp", &format!("{id}:/{name}/.")]) .arg(&target), )?; } @@ -174,6 +174,7 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< .context("Build a release locally before exporting so the backup includes a portable binary")?; fs::copy(executable, stage.join("bin/stack"))?; crate::app::chat::copy_artifacts(project, stage)?; + files::copy_runtime(&project.root, stage)?; Ok(()) } @@ -331,7 +332,29 @@ fn backup_inner( process::line(format_args!("Encrypted export: {}", output.display())) } -pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { +fn restore_caddy(stage: &Path) -> Result<()> { + let root = stage.join("var/data/caddy"); + let legacy = root.join("caddy"); + if !legacy.exists() { + return Ok(()); + } + let data = root.join("data/caddy"); + let config = root.join("config/caddy"); + ensure!( + !data.exists() && !config.exists(), + "Backup contains ambiguous legacy and current Caddy layouts" + ); + files::private_dir(&root.join("data"))?; + fs::rename(&legacy, &data)?; + let autosave = data.join("autosave.json"); + if autosave.exists() { + files::private_dir(&config)?; + fs::rename(autosave, config.join("autosave.json"))?; + } + Ok(()) +} + +pub(super) fn restore(archive: &Path, destination: &Path, require_release: bool) -> Result<()> { ensure!(!destination.exists(), "Restore destination must not exist"); let parent = destination .parent() @@ -357,9 +380,16 @@ pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { files::extract(source, &stage)?; let manifest = files::verify(&stage)?; ensure!( + !require_release || manifest.kind == "release", + "A public release archive is required" + ); + ensure!( manifest.kind == "backup" || manifest.kind == "release", "Unknown archive kind" ); + if manifest.kind == "backup" { + restore_caddy(&stage)?; + } let project = Project::at(&stage)?; let mut settings = project.settings()?; settings.mode = Mode::Local; @@ -390,8 +420,9 @@ pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { #[cfg(test)] mod tests { use std::fs; + use std::os::unix::fs::PermissionsExt; - use anyhow::Result; + use anyhow::{Context, Result}; use crate::app::files; use crate::app::project::{Mode, Project}; @@ -415,9 +446,7 @@ mod tests { restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; anyhow::ensure!((count) == (1), "Test invariant failed"); anyhow::ensure!( - (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) - & 0o777) - == (0o600), + (fs::metadata(snapshot)?.permissions().mode() & 0o777) == (0o600), "Test invariant failed" ); transaction.rollback()?; @@ -438,7 +467,7 @@ mod tests { let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); - super::restore(&archive, &destination)?; + super::restore(&archive, &destination, false)?; anyhow::ensure!( (Project::at(&destination)?.settings()?.mode) == (Mode::Local), "Test invariant failed" @@ -448,26 +477,236 @@ mod tests { "Test invariant failed" ); anyhow::ensure!( - super::restore(&archive, &destination).is_err(), + super::restore(&archive, &destination, false).is_err(), "Test invariant failed" ); Ok(()) } #[test] + fn restore_legacy_caddy_uses_active_mounts_without_changing_the_backup() -> Result<()> { + for autosave in [false, true] { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + let legacy = source.join("var/data/caddy/caddy"); + files::private_dir(&legacy)?; + for (name, bytes, mode) in [ + ("acme/account.key", b"account fixture".as_slice(), 0o600), + ("certificates/site.key", b"key fixture".as_slice(), 0o600), + ( + "certificates/site.crt", + b"certificate fixture".as_slice(), + 0o644, + ), + ] { + files::write(&legacy.join(name), bytes, mode)?; + } + if autosave { + files::write(&legacy.join("autosave.json"), b"config fixture", 0o600)?; + } + files::manifest(&source, "backup")?; + let original = files::inventory(&source)?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let digest = files::hash_file(&archive)?; + let destination = workspace.path().join("restored"); + super::restore(&archive, &destination, false)?; + for name in [ + "acme/account.key", + "certificates/site.key", + "certificates/site.crt", + ] { + let restored = destination.join("var/data/caddy/data/caddy").join(name); + anyhow::ensure!( + fs::read(&restored)? == fs::read(legacy.join(name))?, + "Caddy credential bytes changed" + ); + anyhow::ensure!( + fs::metadata(&restored)?.permissions().mode() & 0o777 + == fs::metadata(legacy.join(name))?.permissions().mode() & 0o777, + "Caddy credential permissions changed" + ); + } + anyhow::ensure!( + fs::metadata(destination.join("var/data/caddy/data/caddy"))? + .permissions() + .mode() + & 0o777 + == 0o700, + "Caddy directory permissions changed" + ); + let config = destination.join("var/data/caddy/config/caddy/autosave.json"); + anyhow::ensure!(config.exists() == autosave, "Autosave presence changed"); + if autosave { + anyhow::ensure!( + fs::read(&config)? == b"config fixture", + "Autosave bytes changed" + ); + anyhow::ensure!( + fs::metadata(config)?.permissions().mode() & 0o777 == 0o600, + "Autosave permissions changed" + ); + } + anyhow::ensure!( + !destination.join("var/data/caddy/caddy").exists() + && !destination + .join("var/data/caddy/data/caddy/autosave.json") + .exists(), + "Legacy Caddy files remain outside their active mounts" + ); + anyhow::ensure!( + files::inventory(&source)? == original, + "Backup source changed" + ); + anyhow::ensure!( + files::hash_file(&archive)? == digest, + "Backup archive changed" + ); + } + Ok(()) + } + + #[test] + fn restore_rejects_ambiguous_caddy_layouts_without_publishing() -> Result<()> { + for name in ["data", "config"] { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::write( + &source.join("var/data/caddy/caddy/site.key"), + b"legacy", + 0o600, + )?; + files::write( + &source + .join("var/data/caddy") + .join(name) + .join("caddy/current"), + b"current", + 0o600, + )?; + files::manifest(&source, "backup")?; + let original = files::inventory(&source)?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let digest = files::hash_file(&archive)?; + let destination = workspace.path().join("restored"); + anyhow::ensure!( + super::restore(&archive, &destination, false).is_err(), + "Ambiguous Caddy layouts were accepted" + ); + anyhow::ensure!(!destination.exists(), "Ambiguous backup was published"); + anyhow::ensure!( + files::inventory(&source)? == original, + "Backup source changed" + ); + anyhow::ensure!( + files::hash_file(&archive)? == digest, + "Backup archive changed" + ); + } + Ok(()) + } + + #[test] + fn restore_preserves_current_caddy_layout() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + let data = source.join("var/data/caddy/data/caddy"); + let config = source.join("var/data/caddy/config/caddy"); + files::write(&data.join("certificates/site.key"), b"key fixture", 0o600)?; + files::write(&config.join("autosave.json"), b"config fixture", 0o600)?; + files::manifest(&source, "backup")?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let destination = workspace.path().join("restored"); + super::restore(&archive, &destination, false)?; + anyhow::ensure!( + files::inventory(&source.join("var/data/caddy"))? + == files::inventory(&destination.join("var/data/caddy"))?, + "Current Caddy layout changed" + ); + anyhow::ensure!( + fs::metadata(destination.join("var/data/caddy/data/caddy/certificates/site.key"))? + .permissions() + .mode() + & 0o777 + == 0o600, + "Current Caddy key permissions changed" + ); + Ok(()) + } + + #[test] + fn release_requirement_rejects_backup_and_accepts_release() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::private_dir(&source)?; + fs::write(source.join(".env"), "private fixture")?; + files::write( + &source.join("var/data/caddy/caddy/site.key"), + b"legacy fixture", + 0o600, + )?; + files::write( + &source.join("var/data/caddy/data/caddy/site.key"), + b"current fixture", + 0o600, + )?; + files::manifest(&source, "backup")?; + let original = files::inventory(&source)?; + let backup = workspace.path().join("backup.tar.gz"); + files::pack(&source, &backup)?; + let destination = workspace.path().join("restored"); + let error = super::restore(&backup, &destination, true) + .err() + .context("Private backup accepted as public release")?; + anyhow::ensure!( + error.to_string().contains("public release archive"), + "Caddy normalization ran before the release guard" + ); + anyhow::ensure!(!destination.exists(), "Rejected backup was published"); + anyhow::ensure!( + files::inventory(&source)? == original, + "Rejected backup changed the original source" + ); + fs::remove_file(source.join(".env"))?; + fs::remove_dir_all(source.join("var/data"))?; + files::manifest(&source, "release")?; + let release = workspace.path().join("release.tar.gz"); + files::pack(&source, &release)?; + super::restore(&release, &destination, true)?; + anyhow::ensure!(destination.is_dir(), "Public release was not restored"); + anyhow::ensure!( + !destination.join(".env").exists(), + "Private backup contents entered public release" + ); + Ok(()) + } + + #[test] fn corrupt_restore_never_publishes_a_destination() -> Result<()> { let workspace = tempfile::tempdir()?; let source = workspace.path().join("source"); files::private_dir(&source)?; fs::write(source.join(".env"), "original")?; + for name in ["caddy", "data/caddy"] { + files::write( + &source.join("var/data/caddy").join(name).join("site.key"), + b"key fixture", + 0o600, + )?; + } files::manifest(&source, "backup")?; fs::write(source.join(".env"), "tampered")?; let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); + let error = super::restore(&archive, &destination, false) + .err() + .context("Corrupt archive was accepted")?; anyhow::ensure!( - super::restore(&archive, &destination).is_err(), - "Test invariant failed" + error.to_string().contains("manifest"), + "Manifest was not verified first" ); anyhow::ensure!(!destination.exists(), "Test invariant failed"); Ok(()) |
