// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions, Permissions}; use std::io::{Read, Write}; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, bail, ensure}; use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use walkdir::WalkDir; pub(crate) const SOURCE: &[&str] = &[ "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo", ".gitignore", ".forgejo", "src", "tests", "crates", "infra", "web", "docs", "README.txt", "LICENSE", "deny.toml", "about.toml", "about.hbs", ".env.example", ]; #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub(crate) struct Manifest { pub(crate) format: u32, pub(crate) kind: String, pub(crate) version: String, pub(crate) created_utc: String, pub(crate) files: BTreeMap, } pub(crate) fn private_dir(path: &Path) -> Result<()> { fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(path)?; fs::set_permissions(path, Permissions::from_mode(0o700))?; Ok(()) } pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { let parent = path.parent().context("File has no parent directory")?; fs::create_dir_all(parent)?; let mut pending = tempfile::NamedTempFile::new_in(parent)?; pending .as_file_mut() .set_permissions(Permissions::from_mode(mode))?; pending.write_all(data)?; pending.as_file().sync_all()?; pending.persist(path).map_err(|error| error.error)?; Ok(()) } pub(crate) fn hash_bytes(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } pub(crate) fn hash_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?; let mut digest = Sha256::new(); let mut buffer = [0; 8192]; loop { let length = file.read(&mut buffer)?; if length == 0 { break; } digest.update(buffer.get(..length).context("Invalid read length")?); } Ok(hex::encode(digest.finalize())) } pub(crate) fn relative(path: &Path, base: &Path) -> Result { Ok(path .strip_prefix(base)? .to_str() .context("Paths must be UTF-8")? .to_owned()) } pub(crate) fn regular_tree(path: &Path) -> Result<()> { for item in WalkDir::new(path).follow_links(false) { let entry = item?; ensure!( entry.file_type().is_dir() || entry.file_type().is_file(), "Links and special files are not accepted: {}", entry.path().display() ); } Ok(()) } pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { regular_tree(source)?; if source.is_file() { let parent = destination.parent().context("File has no parent")?; fs::create_dir_all(parent)?; fs::copy(source, destination)?; return Ok(()); } for item in WalkDir::new(source).follow_links(false) { let entry = item?; let target = destination.join(entry.path().strip_prefix(source)?); if entry.file_type().is_dir() { fs::create_dir_all(&target)?; fs::set_permissions(&target, entry.metadata()?.permissions())?; } else { let parent = target.parent().context("File has no parent")?; fs::create_dir_all(parent)?; fs::copy(entry.path(), &target)?; } } Ok(()) } pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> { for name in SOURCE { let source = root.join(name); if source.exists() { copy_tree(&source, &destination.join(name))?; } } Ok(()) } pub(crate) fn inventory(root: &Path) -> Result> { let mut files = BTreeMap::new(); for item in WalkDir::new(root).follow_links(false) { let entry = item?; ensure!(!entry.file_type().is_symlink(), "Archive contains a link"); if entry.file_type().is_file() { let name = relative(entry.path(), root)?; if name != "manifest.json" { files.insert(name, hash_file(entry.path())?); } } } Ok(files) } pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> { let record = Manifest { format: 2, kind: kind.to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), created_utc: chrono::Utc::now().to_rfc3339(), files: inventory(root)?, }; write( &root.join("manifest.json"), &serde_json::to_vec_pretty(&record)?, 0o600, ) } pub(crate) fn verify(root: &Path) -> Result { let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?; ensure!( record.format == 2, "Unsupported export format; legacy Python exports retain their embedded recovery helper" ); ensure!( inventory(root)? == record.files, "Archive file list or checksums do not match the manifest" ); Ok(record) } pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> { regular_tree(root)?; let file = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(archive)?; let encoder = GzEncoder::new(file, Compression::new(3)); let mut builder = tar::Builder::new(encoder); builder.follow_symlinks(false); builder.append_dir_all("stack", root)?; builder.into_inner()?.finish()?.sync_all()?; Ok(()) } pub(crate) fn member_path(path: &Path, destination: &Path) -> Result { let mut parts = path.components(); ensure!( parts.next() == Some(Component::Normal("stack".as_ref())), "Archive must have a stack root" ); let mut target = destination.to_path_buf(); for part in parts { match part { Component::Normal(name) => target.push(name), _ => bail!("Unsafe archive path: {}", path.display()), } } Ok(target) } pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> { private_dir(destination)?; let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?)); let mut directories = Vec::new(); for item in reader.entries()? { let mut entry = item?; let kind = entry.header().entry_type(); ensure!( kind.is_dir() || kind.is_file(), "Archive links and special files are forbidden" ); let target = member_path(&entry.path()?, destination)?; let mode = entry.header().mode()? & 0o777; if kind.is_dir() { fs::create_dir_all(&target)?; directories.push((target, mode | 0o700)); } else { let parent = target.parent().context("File has no parent")?; fs::create_dir_all(parent)?; let mut output = OpenOptions::new() .write(true) .create_new(true) .mode(mode) .open(&target)?; std::io::copy(&mut entry, &mut output)?; output.sync_all()?; } } for (path, mode) in directories.into_iter().rev() { fs::set_permissions(path, Permissions::from_mode(mode))?; } Ok(()) } #[cfg(test)] mod tests { use std::fs; use std::path::Path; use anyhow::Result; use super::{copy_source, extract, manifest, member_path, pack, verify}; #[test] fn rejects_paths_outside_archive_root() { let destination = Path::new("/restore"); for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { assert!(member_path(Path::new(name), destination).is_err()); } assert_eq!( member_path(Path::new("stack/web/index.html"), destination).ok(), Some(destination.join("web/index.html")) ); } #[test] fn detects_modified_and_extra_files() -> Result<()> { let root = tempfile::tempdir()?; fs::write(root.path().join("public.txt"), "original")?; manifest(root.path(), "release")?; assert!(verify(root.path()).is_ok()); fs::write(root.path().join("public.txt"), "changed")?; assert!(verify(root.path()).is_err()); fs::write(root.path().join("public.txt"), "original")?; fs::write(root.path().join("unexpected.txt"), "extra")?; assert!(verify(root.path()).is_err()); Ok(()) } #[test] fn source_export_excludes_secrets_and_runtime() -> Result<()> { let source = tempfile::tempdir()?; let target = tempfile::tempdir()?; fs::write(source.path().join("Cargo.toml"), "source")?; fs::write(source.path().join(".env"), "private")?; fs::create_dir(source.path().join("var"))?; fs::write(source.path().join("var/private.key"), "private")?; copy_source(source.path(), target.path())?; assert!(target.path().join("Cargo.toml").is_file()); assert!(!target.path().join(".env").exists()); assert!(!target.path().join("var").exists()); Ok(()) } #[test] fn archives_round_trip_without_overwriting() -> Result<()> { let workspace = tempfile::tempdir()?; let source = workspace.path().join("source"); fs::create_dir(&source)?; fs::write(source.join(".env"), "secret")?; manifest(&source, "backup")?; let archive = workspace.path().join("backup.tar.gz"); pack(&source, &archive)?; let restored = workspace.path().join("restored"); extract(&archive, &restored)?; assert!(verify(&restored).is_ok()); assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret"); assert!(extract(&archive, &restored).is_err()); Ok(()) } #[test] fn rejects_symbolic_links() -> Result<()> { let root = tempfile::tempdir()?; std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; assert!(super::regular_tree(root.path()).is_err()); Ok(()) } }