diff options
Diffstat (limited to 'src/files.rs')
| -rw-r--r-- | src/files.rs | 336 |
1 files changed, 0 insertions, 336 deletions
diff --git a/src/files.rs b/src/files.rs deleted file mode 100644 index b939311..0000000 --- a/src/files.rs +++ /dev/null @@ -1,336 +0,0 @@ -// 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(()) - } -} |
