aboutsummaryrefslogtreecommitdiff
path: root/src/files.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 14:00:58 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 14:00:58 -0500
commit19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 (patch)
treede4591ed54ef860f04dd88bb3aae51dee51237a7 /src/files.rs
parent006a1e94075e90fccfe248cd66bf7d0586a86279 (diff)
Manage the portable stack with Rust and rootless Podman
Diffstat (limited to 'src/files.rs')
-rw-r--r--src/files.rs336
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(())
+ }
+}