//! Project export / import (Iteration 5, ADR-0015 §11 + //! ADR-0007). //! //! Export produces a zip containing `/project.yaml` //! and `/data/...`, with the project's directory //! name preserved as the top-level folder inside the archive so //! unzipping recreates a self-contained directory rather than //! scattering files into the recipient's CWD. The derived //! `playground.db` and the user's `history.log` are excluded //! per ADR-0007 amendment 1. //! //! Import is the inverse: open a zip, validate it has a single //! top-level folder containing a `project.yaml`, extract under //! the chosen target. The target name is taken from the zip's //! top-level folder (so an export-then-import round trip //! preserves the original name even if the zip filename was //! changed in transit). Collision on the destination //! auto-suffixes `-02`, `-03`, ... rather than refusing — see //! ADR-0015 §11 amendment. //! //! Path-traversal protection is layered: each entry's name is //! validated via `enclosed_name()` (rejects `..` segments and //! absolute paths), and the resolved extraction path must //! stay under the chosen target. use std::fs; use std::io::{self, Read as _, Write as _}; use std::path::{Component, Path, PathBuf}; use tracing::{debug, info}; use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions}; use crate::project::{ HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local, }; /// File names excluded from `export` zips. These are either /// derived (`playground.db`), per-process (`.lock`), /// per-user-private (`history.log`), or recovery artifacts /// (`.bak` files) — none of them belong in something the user /// shares with strangers. const EXPORT_EXCLUDED_NAMES: &[&str] = &[ PLAYGROUND_DB, HISTORY_LOG, ".rdbms-playground.lock", ]; /// Maximum auto-suffix attempts when resolving a colliding /// import target. After this many tries we give up and surface /// an error — the user can pick a different target with /// `import as ` instead. const IMPORT_SUFFIX_LIMIT: u32 = 99; /// Maximum same-day export sequence number. The two-digit /// pattern in the default filename caps at 99; if a single /// day's exports exhaust the range, the user supplies an /// explicit filename. const EXPORT_SEQUENCE_LIMIT: u32 = 99; #[derive(Debug)] pub enum ArchiveError { Io { path: PathBuf, source: io::Error, }, Zip { path: PathBuf, message: String, }, ExportSequenceExhausted { project: String, target_dir: PathBuf, limit: u32, }, ImportCollisionExhausted { path: PathBuf, limit: u32, }, InvalidZip(String), NotAProjectArchive, MultipleTopFolders, UnsafeEntry(String), } impl std::fmt::Display for ArchiveError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io { path, source } => f.write_str(&crate::t!( "archive.io", path = path.display(), source = source, )), Self::Zip { path, message } => f.write_str(&crate::t!( "archive.zip", path = path.display(), message = message, )), Self::ExportSequenceExhausted { project, target_dir, limit, } => f.write_str(&crate::t!( "archive.export_sequence_exhausted", project = project, target_dir = target_dir.display(), limit = limit, )), Self::ImportCollisionExhausted { path, limit } => { // {limit:02} is a format specifier — the catalog // helper rejects those (ADR-0019 §8.4). Pre-format // the limit before substitution. f.write_str(&crate::t!( "archive.import_collision_exhausted", path = path.display(), limit = format_args!("{limit:02}"), )) } Self::InvalidZip(detail) => f.write_str(&crate::t!( "archive.invalid_zip", detail = detail, )), Self::NotAProjectArchive => { f.write_str(&crate::t!("archive.not_a_project_archive")) } Self::MultipleTopFolders => { f.write_str(&crate::t!("archive.multiple_top_folders")) } Self::UnsafeEntry(entry) => f.write_str(&crate::t!( "archive.unsafe_entry", entry = entry, )), } } } impl std::error::Error for ArchiveError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io { source, .. } => Some(source), _ => None, } } } /// Default export-zip filename for `--export-NN.zip`. /// /// `date_yyyymmdd` is the today-local prefix (8 digits); /// `project_name` is the project's directory name (we use the /// directory name rather than the prettified display name so /// the export filename round-trips cleanly through `import`). #[must_use] pub fn default_export_filename(date_yyyymmdd: &str, project_name: &str, sequence: u32) -> String { format!("{date_yyyymmdd}-{project_name}-export-{sequence:02}.zip") } /// Find the next available `-NN` suffix for the default export /// filename in `target_dir`, starting from 1. Returns /// `ExportSequenceExhausted` if 1..=99 are all taken. pub fn next_export_sequence( target_dir: &Path, project_name: &str, ) -> Result<(String, u32), ArchiveError> { let date = today_local(); for seq in 1..=EXPORT_SEQUENCE_LIMIT { let candidate = default_export_filename(&date, project_name, seq); let candidate_path = target_dir.join(&candidate); if !candidate_path.exists() { return Ok((candidate, seq)); } } Err(ArchiveError::ExportSequenceExhausted { project: project_name.to_string(), target_dir: target_dir.to_path_buf(), limit: EXPORT_SEQUENCE_LIMIT, }) } /// Export the project at `project_path` to `dst_zip`. /// /// `project_name` is the basename used as the top-level folder /// inside the archive (so unzipping creates /// `/...`). `dst_zip` is the full path where the /// archive will be written; the caller is responsible for /// resolving relative paths and picking a non-clobbering /// filename. pub fn export_project( project_path: &Path, project_name: &str, dst_zip: &Path, ) -> Result<(), ArchiveError> { info!( src = %project_path.display(), dst = %dst_zip.display(), project = %project_name, "exporting project", ); if let Some(parent) = dst_zip.parent() && !parent.as_os_str().is_empty() { fs::create_dir_all(parent).map_err(|source| ArchiveError::Io { path: parent.to_path_buf(), source, })?; } let file = fs::File::create(dst_zip).map_err(|source| ArchiveError::Io { path: dst_zip.to_path_buf(), source, })?; let mut writer = ZipWriter::new(file); let options: SimpleFileOptions = SimpleFileOptions::default() .compression_method(CompressionMethod::Deflated) .unix_permissions(0o644); add_directory_entry(&mut writer, project_name, dst_zip)?; add_directory_recursive( &mut writer, project_path, project_name, &options, dst_zip, )?; writer.finish().map_err(|e| ArchiveError::Zip { path: dst_zip.to_path_buf(), message: e.to_string(), })?; Ok(()) } fn add_directory_entry( writer: &mut ZipWriter, name: &str, zip_path: &Path, ) -> Result<(), ArchiveError> { let entry_name = if name.ends_with('/') { name.to_string() } else { format!("{name}/") }; let options = SimpleFileOptions::default().unix_permissions(0o755); writer .add_directory(entry_name, options) .map_err(|e| ArchiveError::Zip { path: zip_path.to_path_buf(), message: e.to_string(), }) } fn add_directory_recursive( writer: &mut ZipWriter, src_dir: &Path, zip_prefix: &str, options: &SimpleFileOptions, zip_path: &Path, ) -> Result<(), ArchiveError> { let entries = fs::read_dir(src_dir).map_err(|source| ArchiveError::Io { path: src_dir.to_path_buf(), source, })?; for entry in entries { let entry = entry.map_err(|source| ArchiveError::Io { path: src_dir.to_path_buf(), source, })?; let path = entry.path(); let name_os = entry.file_name(); let name = name_os.to_string_lossy().into_owned(); if should_exclude_from_export(&name) { debug!(name = %name, "excluding from export"); continue; } let file_type = entry.file_type().map_err(|source| ArchiveError::Io { path: path.clone(), source, })?; let entry_zip_path = format!("{zip_prefix}/{name}"); if file_type.is_dir() { add_directory_entry(writer, &entry_zip_path, zip_path)?; add_directory_recursive(writer, &path, &entry_zip_path, options, zip_path)?; } else if file_type.is_file() { writer .start_file(&entry_zip_path, *options) .map_err(|e| ArchiveError::Zip { path: zip_path.to_path_buf(), message: e.to_string(), })?; let mut f = fs::File::open(&path).map_err(|source| ArchiveError::Io { path: path.clone(), source, })?; io::copy(&mut f, writer).map_err(|source| ArchiveError::Io { path: path.clone(), source, })?; } // Skip symlinks etc. — the project skeleton never // creates them, and silently dropping them in an // export is safer than following them blindly. } Ok(()) } /// Per-name exclusion rules for export. We exclude: /// /// - `playground.db` (always derived; ADR-0007 / ADR-0015 §11) /// - `history.log` (ADR-0007 amendment 1: user-private) /// - `.rdbms-playground.lock` (per-process) /// - `*.tmp` (atomic-write staging files) /// - `project.yaml.v*.bak` (migration backups; recipient /// doesn't need our local recovery aids) fn should_exclude_from_export(name: &str) -> bool { if EXPORT_EXCLUDED_NAMES.contains(&name) { return true; } if name.ends_with(".tmp") { return true; } if name.starts_with("project.yaml.v") && name.ends_with(".bak") { return true; } false } /// Inspect `zip_path` and return the single top-level folder /// name inside it, plus a `Vec` of every entry path (used by /// the import step to extract). /// /// Refuses if the zip contains zero top-level folders, more /// than one, or no `project.yaml` directly under the single /// top-level folder. This is the load-bearing check that /// distinguishes "an export from this app" from "any old zip /// the user happened to have." pub fn inspect_zip(zip_path: &Path) -> Result { let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io { path: zip_path.to_path_buf(), source, })?; let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip { path: zip_path.to_path_buf(), message: e.to_string(), })?; let mut top_folder: Option = None; let mut saw_project_yaml = false; for i in 0..archive.len() { let entry = archive.by_index(i).map_err(|e| ArchiveError::Zip { path: zip_path.to_path_buf(), message: e.to_string(), })?; let Some(safe_path) = entry.enclosed_name() else { return Err(ArchiveError::UnsafeEntry(entry.name().to_string())); }; let mut comps = safe_path.components(); let Some(Component::Normal(first)) = comps.next() else { // Empty or odd path — ignore. continue; }; let first_str = first.to_string_lossy().into_owned(); match &top_folder { None => top_folder = Some(first_str.clone()), Some(existing) if *existing == first_str => {} Some(_) => return Err(ArchiveError::MultipleTopFolders), } let rest: PathBuf = comps.collect(); if rest == Path::new(PROJECT_YAML) { saw_project_yaml = true; } } let top_folder = top_folder.ok_or(ArchiveError::NotAProjectArchive)?; if !saw_project_yaml { return Err(ArchiveError::NotAProjectArchive); } Ok(ZipInspection { top_folder }) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ZipInspection { /// The single top-level folder name inside the zip. Used /// as the default target project name for import. pub top_folder: String, } /// Resolve a colliding target directory by appending `-NN` /// until a free slot is found. `parent` is the directory the /// project will live in; `name` is the desired basename. /// /// Returns the resolved target path and the suffix that was /// applied (0 if the original name was free, 2..=99 otherwise). pub fn resolve_import_target( parent: &Path, name: &str, ) -> Result<(PathBuf, u32), ArchiveError> { let direct = parent.join(name); if !direct.exists() { return Ok((direct, 0)); } for n in 2..=IMPORT_SUFFIX_LIMIT { let candidate = parent.join(format!("{name}-{n:02}")); if !candidate.exists() { return Ok((candidate, n)); } } Err(ArchiveError::ImportCollisionExhausted { path: direct, limit: IMPORT_SUFFIX_LIMIT, }) } /// Extract `zip_path` into `target_dir` (which must not yet /// exist). The zip's single top-level folder is unwrapped: the /// archive's `/foo/bar` lands at /// `target_dir/foo/bar`. /// /// `inspect_zip` should have been called first to validate the /// shape; this function trusts that result and focuses on the /// extraction. Each entry is checked for path traversal one /// more time before being written. pub fn extract_into( zip_path: &Path, target_dir: &Path, expected_top_folder: &str, ) -> Result<(), ArchiveError> { if target_dir.exists() { return Err(ArchiveError::Io { path: target_dir.to_path_buf(), source: io::Error::new( io::ErrorKind::AlreadyExists, "target directory already exists", ), }); } fs::create_dir_all(target_dir).map_err(|source| ArchiveError::Io { path: target_dir.to_path_buf(), source, })?; let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io { path: zip_path.to_path_buf(), source, })?; let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip { path: zip_path.to_path_buf(), message: e.to_string(), })?; for i in 0..archive.len() { let mut entry = archive.by_index(i).map_err(|e| ArchiveError::Zip { path: zip_path.to_path_buf(), message: e.to_string(), })?; let Some(safe_path) = entry.enclosed_name() else { return Err(ArchiveError::UnsafeEntry(entry.name().to_string())); }; let mut comps = safe_path.components(); let Some(Component::Normal(first)) = comps.next() else { continue; }; if first.to_string_lossy() != expected_top_folder { return Err(ArchiveError::MultipleTopFolders); } let rest: PathBuf = comps.collect(); let dst_path = if rest.as_os_str().is_empty() { // The top-folder entry itself (a directory). Skip // — `target_dir` already exists. continue; } else { target_dir.join(&rest) }; // Defence-in-depth: confirm the resolved path stays // under target_dir even after components collected. if !dst_path.starts_with(target_dir) { return Err(ArchiveError::UnsafeEntry(entry.name().to_string())); } if entry.is_dir() { fs::create_dir_all(&dst_path).map_err(|source| ArchiveError::Io { path: dst_path.clone(), source, })?; } else { if let Some(parent) = dst_path.parent() { fs::create_dir_all(parent).map_err(|source| ArchiveError::Io { path: parent.to_path_buf(), source, })?; } let mut out = fs::File::create(&dst_path).map_err(|source| ArchiveError::Io { path: dst_path.clone(), source, })?; let mut buf = Vec::with_capacity(entry.size() as usize); entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io { path: dst_path.clone(), source, })?; out.write_all(&buf).map_err(|source| ArchiveError::Io { path: dst_path.clone(), source, })?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::project::GITIGNORE; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } fn make_project(root: &Path, name: &str) -> PathBuf { let p = root.join(name); fs::create_dir_all(&p).unwrap(); fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap(); fs::create_dir_all(p.join("data")).unwrap(); fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap(); fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap(); fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap(); fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap(); // Stray atomic-write staging file — must be excluded. fs::write(p.join("project.yaml.tmp"), "stale").unwrap(); p } #[test] fn export_excludes_db_history_lock() { let tmp = tempdir(); let project = make_project(tmp.path(), "MyProject"); let zip_path = tmp.path().join("export.zip"); export_project(&project, "MyProject", &zip_path).unwrap(); // Re-open and list entries. let f = fs::File::open(&zip_path).unwrap(); let mut archive = ZipArchive::new(f).unwrap(); let names: Vec = (0..archive.len()) .map(|i| archive.by_index(i).unwrap().name().to_string()) .collect(); assert!( names.iter().any(|n| n == "MyProject/project.yaml"), "names: {names:?}" ); assert!(names.iter().any(|n| n == "MyProject/data/Customers.csv")); assert!(!names.iter().any(|n| n.contains("playground.db"))); assert!(!names.iter().any(|n| n.contains("history.log"))); assert!(!names.iter().any(|n| n.contains(".lock"))); assert!(!names.iter().any(|n| n.ends_with(".tmp"))); // .gitignore IS included (sensible default for the recipient). assert!(names.iter().any(|n| n == "MyProject/.gitignore")); } #[test] fn export_top_level_folder_is_project_name() { let tmp = tempdir(); let project = make_project(tmp.path(), "MyProject"); let zip_path = tmp.path().join("export.zip"); export_project(&project, "MyProject", &zip_path).unwrap(); let inspect = inspect_zip(&zip_path).unwrap(); assert_eq!(inspect.top_folder, "MyProject"); } #[test] fn inspect_rejects_zip_without_project_yaml() { let tmp = tempdir(); let zip_path = tmp.path().join("notaproject.zip"); let f = fs::File::create(&zip_path).unwrap(); let mut w = ZipWriter::new(f); w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap(); w.write_all(b"hi").unwrap(); w.finish().unwrap(); let err = inspect_zip(&zip_path).expect_err("must refuse"); assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}"); } #[test] fn inspect_rejects_zip_with_multiple_top_folders() { let tmp = tempdir(); let zip_path = tmp.path().join("multi.zip"); let f = fs::File::create(&zip_path).unwrap(); let mut w = ZipWriter::new(f); w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap(); w.write_all(b"x").unwrap(); w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap(); w.write_all(b"x").unwrap(); w.finish().unwrap(); let err = inspect_zip(&zip_path).expect_err("must refuse"); assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}"); } #[test] fn extract_into_unwraps_top_folder() { let tmp = tempdir(); let project = make_project(tmp.path(), "Source"); let zip_path = tmp.path().join("source.zip"); export_project(&project, "Source", &zip_path).unwrap(); let target = tmp.path().join("imported"); extract_into(&zip_path, &target, "Source").unwrap(); assert!(target.join(PROJECT_YAML).exists()); assert!(target.join("data").join("Customers.csv").exists()); // history.log NOT present (it was excluded from export). assert!(!target.join(HISTORY_LOG).exists()); } #[test] fn extract_into_refuses_existing_target() { let tmp = tempdir(); let project = make_project(tmp.path(), "Source"); let zip_path = tmp.path().join("source.zip"); export_project(&project, "Source", &zip_path).unwrap(); let target = tmp.path().join("existing"); fs::create_dir(&target).unwrap(); let err = extract_into(&zip_path, &target, "Source").expect_err("must refuse"); assert!(matches!(err, ArchiveError::Io { .. }), "got: {err:?}"); } #[test] fn resolve_import_target_uses_direct_when_free() { let tmp = tempdir(); let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap(); assert_eq!(path, tmp.path().join("Foo")); assert_eq!(n, 0); } #[test] fn resolve_import_target_appends_suffix_on_collision() { let tmp = tempdir(); fs::create_dir(tmp.path().join("Foo")).unwrap(); let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap(); assert_eq!(path, tmp.path().join("Foo-02")); assert_eq!(n, 2); // Add Foo-02 and try again. fs::create_dir(&path).unwrap(); let (path3, n3) = resolve_import_target(tmp.path(), "Foo").unwrap(); assert_eq!(path3, tmp.path().join("Foo-03")); assert_eq!(n3, 3); } #[test] fn next_export_sequence_starts_at_one() { let tmp = tempdir(); let (name, n) = next_export_sequence(tmp.path(), "MyProject").unwrap(); assert_eq!(n, 1); assert!(name.contains("MyProject")); assert!(name.ends_with("-01.zip")); } #[test] fn next_export_sequence_skips_taken_slots() { let tmp = tempdir(); let date = today_local(); // Pre-create -01 and -02. fs::write(tmp.path().join(default_export_filename(&date, "P", 1)), "").unwrap(); fs::write(tmp.path().join(default_export_filename(&date, "P", 2)), "").unwrap(); let (_, n) = next_export_sequence(tmp.path(), "P").unwrap(); assert_eq!(n, 3); } #[test] fn round_trip_export_then_inspect() { let tmp = tempdir(); let project = make_project(tmp.path(), "Customers"); let zip = tmp.path().join("export.zip"); export_project(&project, "Customers", &zip).unwrap(); let inspect = inspect_zip(&zip).unwrap(); assert_eq!(inspect.top_folder, "Customers"); } }