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