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 | |
| parent | 5b89c370d5974f057306a52089f14c337565fd1d (diff) | |
feat(guix): carry prebuilt runtime and recover preserved state
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/archive.rs | 263 | ||||
| -rw-r--r-- | src/app/cli.rs | 21 | ||||
| -rw-r--r-- | src/app/files.rs | 77 | ||||
| -rw-r--r-- | src/app/images.rs | 29 | ||||
| -rw-r--r-- | src/app/release.rs | 2 |
5 files changed, 377 insertions, 15 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(()) diff --git a/src/app/cli.rs b/src/app/cli.rs index 1830ec1..f886812 100644 --- a/src/app/cli.rs +++ b/src/app/cli.rs @@ -72,6 +72,7 @@ pub(super) enum Action { Restore { archive: PathBuf, destination: PathBuf, + release: bool, }, Check, @@ -135,6 +136,7 @@ fn archives() -> [Command; 4] { .args([ path("archive").required(true), path("destination").required(true), + flag("release").help("Require a public release archive"), ]), Command::new("release") .about("Check and build a portable production release locally") @@ -227,6 +229,7 @@ fn action(name: &str, args: &mut ArgMatches) -> Result<Action, clap::Error> { "restore" => Action::Restore { archive: required(args, "archive")?, destination: required(args, "destination")?, + release: args.get_flag("release"), }, "check" => Action::Check, "release" => Action::Release { @@ -272,6 +275,22 @@ mod tests { use std::path::PathBuf; #[test] + fn restore_accepts_release_requirement() -> Result<()> { + let parsed = Cli::try_parse_from([ + "stack", + "restore", + "--release", + "release.tar.gz", + "new-root", + ])?; + anyhow::ensure!( + matches!(parsed.command, Action::Restore { release: true, .. }), + "Release requirement missing" + ); + Ok(()) + } + + #[test] fn checked_command_schema() { command().debug_assert(); } @@ -321,7 +340,7 @@ mod tests { anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed"); } anyhow::ensure!( - matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), + matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination, release: false } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), "Test invariant failed" ); Ok(()) diff --git a/src/app/files.rs b/src/app/files.rs index 2c612a9..e96e5a4 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -161,6 +161,34 @@ pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> { Ok(()) } +pub(super) fn copy_runtime(root: &Path, destination: &Path) -> Result<()> { + let archive = root.join("bin/guix-runtime.tar.gz"); + let channels = root.join("bin/guix-channels.scm"); + ensure!( + archive.is_file() == channels.is_file(), + "Guix runtime archive and channel provenance must both be present" + ); + if archive.is_file() { + let bin = destination.join("bin"); + fs::create_dir_all(&bin)?; + for name in ["guix-runtime.tar.gz", "guix-channels.scm"] { + let source = root.join("bin").join(name); + regular_tree(&source)?; + let pending = tempfile::NamedTempFile::new_in(&bin)?; + fs::copy(source, pending.path())?; + pending.as_file().sync_all()?; + pending + .persist(bin.join(name)) + .map_err(|error| error.error)?; + } + } + let launcher = root.join("infra/guix/run.sh"); + if launcher.is_file() { + write(&destination.join("bin/run"), &fs::read(launcher)?, 0o755)?; + } + Ok(()) +} + pub(super) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> { let mut files = BTreeMap::new(); for item in WalkDir::new(root).follow_links(false) { @@ -279,6 +307,55 @@ mod tests { use super::{copy_source, extract, manifest, member_path, pack, verify}; #[test] + fn runtime_artifacts_survive_archive_round_trip() -> Result<()> { + let source = tempfile::tempdir()?; + let stage = tempfile::tempdir()?; + fs::create_dir_all(source.path().join("bin"))?; + fs::create_dir_all(source.path().join("infra/guix"))?; + fs::write(source.path().join("infra/guix/run.sh"), "launcher")?; + super::copy_runtime(source.path(), stage.path())?; + anyhow::ensure!(stage.path().join("bin/run").is_file(), "Launcher missing"); + fs::write(source.path().join("bin/guix-runtime.tar.gz"), "opaque pack")?; + anyhow::ensure!( + super::copy_runtime(source.path(), stage.path()).is_err(), + "Missing provenance accepted" + ); + fs::write( + source.path().join("bin/guix-channels.scm"), + "pinned channels", + )?; + super::copy_runtime(source.path(), stage.path())?; + fs::set_permissions( + stage.path().join("bin/guix-runtime.tar.gz"), + std::os::unix::fs::PermissionsExt::from_mode(0o400), + )?; + fs::write( + source.path().join("bin/guix-runtime.tar.gz"), + "updated pack", + )?; + super::copy_runtime(source.path(), stage.path())?; + anyhow::ensure!( + fs::read_to_string(stage.path().join("bin/guix-runtime.tar.gz"))? == "updated pack", + "Read-only runtime was not replaced" + ); + manifest(stage.path(), "backup")?; + let output = source.path().join("backup.tar.gz"); + pack(stage.path(), &output)?; + let restored = source.path().join("restored"); + extract(&output, &restored)?; + verify(&restored)?; + anyhow::ensure!( + fs::read_to_string(restored.join("bin/guix-runtime.tar.gz"))? == "updated pack", + "Runtime changed during recovery" + ); + anyhow::ensure!( + fs::read_to_string(restored.join("bin/guix-channels.scm"))? == "pinned channels", + "Provenance changed during recovery" + ); + Ok(()) + } + + #[test] fn rejects_paths_outside_archive_root() -> Result<()> { let destination = Path::new("/restore"); for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { diff --git a/src/app/images.rs b/src/app/images.rs index fac7aa7..b1105c2 100644 --- a/src/app/images.rs +++ b/src/app/images.rs @@ -130,10 +130,22 @@ fn save_inner( }, ); } + let policy = tempfile::NamedTempFile::new()?; + serde_json::to_writer( + policy.as_file(), + &serde_json::json!({ + "default": [{"type": "reject"}], + "transports": { + "containers-storage": {"": [{"type": "insecureAcceptAnything"}]} + } + }), + )?; let mut command = project.command("podman")?; command + .args(["save", "--signature-policy"]) + .arg(policy.path()); + command .args([ - "save", "--multi-image-archive", "--format", "docker-archive", @@ -178,10 +190,23 @@ pub(super) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> archive.is_file(), "Release image archive is missing; deploy a complete release" ); + let policy = tempfile::NamedTempFile::new()?; + serde_json::to_writer( + policy.as_file(), + &serde_json::json!({ + "default": [{"type": "reject"}], + "transports": { + "docker-archive": {"": [{"type": "insecureAcceptAnything"}]}, + "oci-archive": {"": [{"type": "insecureAcceptAnything"}]} + } + }), + )?; process::run( project .command("podman")? - .args(["image", "load", "--input"]) + .args(["image", "load", "--signature-policy"]) + .arg(policy.path()) + .arg("--input") .arg(archive), )?; } diff --git a/src/app/release.rs b/src/app/release.rs index 268a3c2..40d8d72 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -47,6 +47,7 @@ pub(super) fn create(project: &Project, destination: &Path) -> Result<()> { files::private_dir(&stage)?; files::copy_source(&project.root, &stage)?; crate::app::chat::copy_artifacts(project, &stage)?; + files::copy_runtime(&project.root, &stage)?; fs::create_dir_all(stage.join("bin"))?; fs::copy( project @@ -116,6 +117,7 @@ pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> { 0o755, )?; crate::app::chat::copy_artifacts(&pending, &project.root)?; + files::copy_runtime(&stage, &project.root)?; for name in ["images.tar", "image-lock.json"] { files::copy_tree( &stage.join("var").join(name), |
