Iteration 5: export / import commands
Implements the `export` and `import` app-level commands per ADR-0015 §11 + ADR-0007 amendment 1. - `export [<path>]` writes a zip of project.yaml + data/ to <data-root>/YYYYMMDD-<projectname>-export-NN.zip by default, preserving the project's directory name as the single top-level folder inside the archive. - `import <zip> [as <target>]` extracts an exported zip into a new named project and switches to it. Target name is derived from the zip's top-level folder by default; on collision the destination auto-suffixes -02, -03, ... up to -99 instead of refusing (deviates from §2's refuse-on- collision rule for save/save as; recorded as an amendment to ADR-0015 §11). - Excludes playground.db and history.log from the zip. - Path-traversal protection via zip::enclosed_name + post- resolution check that the extraction path stays inside the target directory. Adds the zip = "5" dep with default-features = false + features = ["deflate"] to keep the binary-size cost modest. Test baseline: 370 passing, 0 failing, 0 skipped.
This commit is contained in:
+626
@@ -0,0 +1,626 @@
|
||||
//! Project export / import (Iteration 5, ADR-0015 §11 +
|
||||
//! ADR-0007).
|
||||
//!
|
||||
//! Export produces a zip containing `<projectname>/project.yaml`
|
||||
//! and `<projectname>/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 <zip> as <target>` 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, thiserror::Error)]
|
||||
pub enum ArchiveError {
|
||||
#[error("io error on `{}`: {source}", path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("zip error on `{}`: {message}", path.display())]
|
||||
Zip { path: PathBuf, message: String },
|
||||
#[error(
|
||||
"could not pick an export filename for `{project}` in `{}`: \
|
||||
all sequence numbers up to {limit} are taken",
|
||||
target_dir.display(),
|
||||
)]
|
||||
ExportSequenceExhausted {
|
||||
project: String,
|
||||
target_dir: PathBuf,
|
||||
limit: u32,
|
||||
},
|
||||
#[error(
|
||||
"destination `{}` already exists and the auto-suffix retries \
|
||||
(-02 through -{limit:02}) are also taken; use \
|
||||
`import <zip> as <target>` to choose a different name",
|
||||
path.display(),
|
||||
)]
|
||||
ImportCollisionExhausted { path: PathBuf, limit: u32 },
|
||||
#[error("zip is malformed: {0}")]
|
||||
InvalidZip(String),
|
||||
#[error("zip does not contain a project (no `project.yaml` under a single top-level folder)")]
|
||||
NotAProjectArchive,
|
||||
#[error("zip contains more than one top-level folder; refusing to extract")]
|
||||
MultipleTopFolders,
|
||||
#[error(
|
||||
"zip entry `{0}` would escape the target directory; refusing to extract"
|
||||
)]
|
||||
UnsafeEntry(String),
|
||||
}
|
||||
|
||||
/// Default export-zip filename for `<date>-<project>-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
|
||||
/// `<project_name>/...`). `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<fs::File>,
|
||||
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<fs::File>,
|
||||
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<ZipInspection, ArchiveError> {
|
||||
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<String> = 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 `<top_folder>/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<String> = (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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user