summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.rs44
-rw-r--r--src/app/archive.rs263
-rw-r--r--src/app/cli.rs21
-rw-r--r--src/app/files.rs77
-rw-r--r--src/app/images.rs29
-rw-r--r--src/app/release.rs2
6 files changed, 404 insertions, 32 deletions
diff --git a/src/app.rs b/src/app.rs
index d655774..02f91ee 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -61,9 +61,10 @@ fn execute(arguments: Cli) -> Result<()> {
if let Action::Restore {
archive,
destination,
+ release,
} = &arguments.command
{
- return archive::restore(&absolute(archive)?, &absolute(destination)?);
+ return archive::restore(&absolute(archive)?, &absolute(destination)?, *release);
}
let project = Project::discover(arguments.root.as_deref())?;
if let Action::Chat {
@@ -132,22 +133,13 @@ fn execute(arguments: Cli) -> Result<()> {
include_images,
recipient,
key,
- } => {
- let output = destination.map_or_else(
- || Ok(project.root.join("var/exports")),
- |path| absolute(&path),
- )?;
- let configuration = stack_config::Config::load(&project.root)?;
- let selected_recipient =
- recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
- archive::backup(
- &project,
- &output,
- include_images,
- &selected_recipient,
- key.as_deref(),
- )
- }
+ } => backup(
+ &project,
+ destination,
+ include_images,
+ recipient,
+ key.as_deref(),
+ ),
Action::Release { destination } => {
let output = destination
.map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?;
@@ -161,6 +153,23 @@ fn execute(arguments: Cli) -> Result<()> {
}
}
+fn backup(
+ project: &Project,
+ destination: Option<PathBuf>,
+ include_images: bool,
+ recipient: Option<String>,
+ key: Option<&Path>,
+) -> Result<()> {
+ let output = destination.map_or_else(
+ || Ok(project.root.join("var/exports")),
+ |path| absolute(&path),
+ )?;
+ let configuration = stack_config::Config::load(&project.root)?;
+ let selected_recipient =
+ recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
+ archive::backup(project, &output, include_images, &selected_recipient, key)
+}
+
fn chat(
project: &Project,
init: bool,
@@ -207,6 +216,7 @@ fn licenses(project: &Project) -> Result<()> {
}
fn check(project: &Project) -> Result<()> {
+ process::run(project.command("sh")?.arg("infra/guix/test.sh"))?;
process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?;
process::run(
project
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),