diff options
Diffstat (limited to 'src/files.rs')
| -rw-r--r-- | src/files.rs | 336 |
1 files changed, 336 insertions, 0 deletions
diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..b939311 --- /dev/null +++ b/src/files.rs @@ -0,0 +1,336 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// 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 <https://www.gnu.org/licenses/>. + +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<String, String>, +} + +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<String> { + 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<String> { + 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<BTreeMap<String, String>> { + 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<Manifest> { + 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<PathBuf> { + 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(()) + } +} |
