summaryrefslogtreecommitdiff
path: root/src/app/archive.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/archive.rs')
-rw-r--r--src/app/archive.rs263
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(())