diff --git a/CLAUDE.md b/CLAUDE.md
index 0086c02d..94a1ab6f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,11 +81,13 @@ ant-core/src/
│ ├── link.rs # ant://
and ant://manifest/ links
│ ├── file.rs # .ant file read/write
│ ├── history.rs # Upload history: one .ant per upload under /uploads
+│ ├── journal.rs # Crash-safe journal of a running build (.ant.partial)
│ ├── wire.rs # Manifest-owned DataMap layout; pre-decode structure check
│ ├── build.rs # ManifestBuilder (uploads files, embedded or compact refs)
│ ├── embed.rs # Root-vs-shrunk DataMap choice for embedding
│ ├── compact.rs # Plan/publish/apply compaction (embedded DataMaps -> addresses)
-│ └── extract.rs # Containment-safe extraction with per-entry results
+│ ├── extract.rs # Containment-safe extraction with per-entry results
+│ └── output/ # Handle-relative output directories (fds on Unix, held handles on Windows)
└── node/ # Node management
├── mod.rs # add_nodes, remove_node, reset
├── types.rs # DaemonConfig, DaemonStatus, NodeConfig, NodeInfo, AddNodeOpts, etc.
@@ -150,7 +152,7 @@ cargo run --bin ant -- --help # Run the CLI
- **CLI output**: All commands support `--json` global flag. Human-readable output by default, structured JSON when `--json` is passed.
- **Hidden commands**: `ant node daemon run` is hidden (runs daemon in foreground, used internally by `daemon start`).
- **REST API conflict responses**: All 409 responses include a `current_state` field so retrying clients can confirm desired state already exists.
-- **Platform paths**: Use `ant_core::config::data_dir()` and `ant_core::config::log_dir()` for platform-appropriate paths. Never hardcode paths.
+- **Platform paths**: Use `ant_core::config::data_dir()`, `ant_core::config::log_dir()` and `ant_core::config::download_dir()` for platform-appropriate paths. Never hardcode paths.
- **OpenAPI schema**: Types exposed in the REST API derive `utoipa::ToSchema`. Use `#[schema(value_type = String)]` for `PathBuf` fields.
- **Tests**: Unit tests go in `#[cfg(test)] mod tests` inside the source file. Integration tests go in `ant-core/tests/`.
- **Progress reporting**: Long-running operations (e.g., binary downloads) accept a `&dyn ProgressReporter` from `ant_core::node::binary`. CLI provides a terminal-printing implementation; use `NoopProgress` in tests and daemon API handlers.
@@ -161,8 +163,13 @@ cargo run --bin ant -- --help # Run the CLI
is shared off-network; only the files it references live on Autonomi. `ant://` is always
a plain file link. Every upload (`ant file upload`, `ant manifest create`) is also recorded as
a manifest under `/uploads/`; `ant manifest list` shows them and `show`/`link`/`export`/`download`
- accept a record id. Entry paths obey the portable rules in `data/manifest/path.rs` at build,
- decode and extract time, and extraction never follows symlinks inside the output directory.
+ accept a record id. Without `--output`, `download` extracts into a new directory named after
+ the manifest in the user's downloads directory. Entry paths obey the portable rules in `data/manifest/path.rs` at build,
+ decode and extract time, and extraction never follows symlinks inside the output directory
+ and resolves every name from directories it holds open (`data/manifest/output/`: by
+ descriptor on Unix, by path under held handles on Windows). A manifest
+ build journals each completed upload to `.ant.partial` before paying for the next, so a
+ crash loses no paid upload; journals list as unfinished records.
See `docs/adr/ADR-0006-file-manifests-and-links.md`.
- **Log forwarding is opt-in and node-logging-dependent**: `ant node logs forward enable` is the
consent act. It only forwards nodes whose `NodeConfig.log_dir` is `Some` — node file logging is
diff --git a/Cargo.lock b/Cargo.lock
index cca5c7c6..166fa5dc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -840,6 +840,7 @@ dependencies = [
"rmp-serde",
"serde",
"serde_json",
+ "sha2",
"tempfile",
"tokio",
"tokio-util",
@@ -863,6 +864,8 @@ dependencies = [
"blake3",
"bytes",
"console_error_panic_hook",
+ "crc32fast",
+ "dirs",
"fips204",
"flate2",
"fs2",
@@ -880,6 +883,7 @@ dependencies = [
"rand 0.8.6",
"reqwest 0.12.28",
"rmp-serde",
+ "rustix",
"saorsa-transport",
"self-replace",
"self_encryption",
@@ -901,6 +905,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
+ "unicase",
"unicode-normalization",
"url",
"utoipa",
diff --git a/ant-cli/Cargo.toml b/ant-cli/Cargo.toml
index 78e2b809..86a2bcb6 100644
--- a/ant-cli/Cargo.toml
+++ b/ant-cli/Cargo.toml
@@ -23,6 +23,7 @@ rand = "0.8"
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
tracing = "0.1"
diff --git a/ant-cli/src/commands/data/file.rs b/ant-cli/src/commands/data/file.rs
index bd1aab8a..056e825d 100644
--- a/ant-cli/src/commands/data/file.rs
+++ b/ant-cli/src/commands/data/file.rs
@@ -801,7 +801,7 @@ struct UploadJsonResult {
/// Tell the user where the upload was recorded.
fn print_recorded(manifest_id: Option<&str>) {
if let Some(id) = manifest_id {
- println!("Recorded as {id} (ant manifest download {id})");
+ println!("Recorded as {id} (ant manifest download {id} --output )");
}
}
diff --git a/ant-cli/src/commands/data/manifest.rs b/ant-cli/src/commands/data/manifest.rs
index 1d103d57..f3452ea4 100644
--- a/ant-cli/src/commands/data/manifest.rs
+++ b/ant-cli/src/commands/data/manifest.rs
@@ -17,13 +17,15 @@ use ant_core::data::{
apply_compaction, data_map_address, default_history_dir, embeddable_data_map, extract_manifest,
format_timestamp, is_link, list_uploads, load_upload, manifest_filename_for,
manifest_link_bytes, manifest_link_from_bytes, parse_link, plan_compaction, publish_data_maps,
- read_manifest_file, record_upload, write_manifest_file, BuildEvent, BuildOptions, BuildResult,
- Client, ContentRef, DownloadEvent, EntryStatus, ExtractEvent, ExtractOptions, Link, Manifest,
- ManifestBuilder, ManifestError, PaymentMode, ReferenceMode, TorrentReference, UploadEvent,
- UploadRecord, Visibility, MANIFEST_LINK_RECOMMENDED_MAX_BYTES,
+ read_manifest_file, record_upload, select_entries, write_manifest_file, BuildEvent,
+ BuildOptions, BuildResult, Client, ContentRef, DownloadEvent, EntryStatus, ExtractEvent,
+ ExtractOptions, Link, Manifest, ManifestBuilder, ManifestError, PaymentMode, RecordState,
+ ReferenceMode, TorrentReference, UploadEvent, UploadRecord, Visibility,
+ MANIFEST_LINK_RECOMMENDED_MAX_BYTES,
};
use clap::Subcommand;
use serde_json::json;
+use sha2::{Digest, Sha256};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
@@ -42,6 +44,12 @@ const YES_ANSWERS: &[&str] = &["y", "yes"];
const FORCE_QUIT_EXIT_CODE: i32 = 130;
/// Marks a size in `manifest show` that only the manifest's creator vouches for.
const CLAIMED_SIZE_MARK: char = '*';
+/// Extension of a stopped build's partial manifest, written beside an
+/// output file that already exists rather than over it.
+const PARTIAL_MANIFEST_EXTENSION: &str = "partial.ant";
+/// Directory under the data directory that records the default download
+/// directories whose download has not finished, so a re-run can continue.
+const DOWNLOAD_RECORDS_DIR: &str = "downloads";
/// Manifest subcommands.
#[derive(Subcommand, Debug)]
@@ -143,8 +151,13 @@ pub enum ManifestAction {
/// A `.ant` file, an `ant://manifest/...` link, or an upload id
/// from `ant manifest list`.
source: String,
- /// Directory to extract into. Defaults to the manifest name, or
- /// the current directory when it has none.
+ /// Directory to extract into, existing or not. Defaults to a new
+ /// directory named after the manifest in your downloads directory,
+ /// which must not exist yet unless an earlier run of this download
+ /// made it and did not finish. A manifest without a name, or with a
+ /// hidden one, needs --output. Files already in place with their
+ /// recorded SHA-256 are kept, so a download that stopped can be run
+ /// again.
#[arg(short, long)]
output: Option,
/// Only extract these entries or directories. Repeatable.
@@ -491,6 +504,9 @@ async fn create(client: &Client, args: CreateArgs, json: bool) -> anyhow::Result
payment_mode: args.payment_mode,
follow_symlinks: args.follow_symlinks,
cancel: CancellationToken::new(),
+ // Each completed upload is journaled here before the next is paid
+ // for; a build that cannot record itself stops before paying.
+ history: Some(default_history_dir()?),
};
// Fail before any paid upload if the manifest could not be written.
let out = resolve_output(options.name.as_deref(), args.output.clone())?;
@@ -547,8 +563,7 @@ async fn create(client: &Client, args: CreateArgs, json: bool) -> anyhow::Result
result
};
- let (result, history_id) =
- record_build_result(built, |manifest| record_upload_manifest(manifest, None))?;
+ let (result, history_id) = build_outcome(built, &out)?;
write_manifest(&result.manifest, &out, args.overwrite)?;
if !json {
eprintln!(
@@ -571,10 +586,13 @@ async fn create(client: &Client, args: CreateArgs, json: bool) -> anyhow::Result
)
}
-/// Persist completed uploads before reporting cancellation or failure.
-fn record_build_result(
+/// Turn a build's outcome into its result and the id of the record that
+/// holds it, or an error that says where the completed uploads are kept. The
+/// builder has already recorded them in the history, complete or partial;
+/// when it could not, a stopped build's partial manifest is written to `out`.
+fn build_outcome(
built: Result,
- record: impl FnOnce(&Manifest) -> Option,
+ out: &Path,
) -> anyhow::Result<(BuildResult, Option)> {
let result = match built {
Ok(result) => result,
@@ -583,24 +601,64 @@ fn record_build_result(
cause,
partial,
}) => {
- let history_id = record(&partial.manifest);
+ let at = if path.is_empty() {
+ String::new()
+ } else {
+ format!(" at {path:?}")
+ };
anyhow::bail!(
- "manifest build failed at {path:?} after {} of {} file(s): {cause}{}",
+ "manifest build failed{at} after {} of {} file(s): {cause}{}",
partial.files_uploaded,
partial.total_to_upload,
- partial_record_message(history_id.as_deref()),
+ keep_partial(&partial, out),
);
}
Err(error) => return Err(error.into()),
};
-
- let history_id = record(&result.manifest);
if result.cancelled {
- return Err(cancelled_build_error(&result, history_id.as_deref()));
+ return Err(cancelled_build_error(&result, &keep_partial(&result, out)));
}
+ let history_id = recorded_id(&result);
Ok((result, history_id))
}
+/// The id of the record that holds a build's manifest, or `None`, with a
+/// warning, when its record could not be written.
+fn recorded_id(result: &BuildResult) -> Option {
+ match (&result.history_id, &result.history_error) {
+ (Some(id), Some(error)) => {
+ eprintln!(
+ "warning: the record of upload {id} could not be written ({error}); \
+ its journal stays in the history, listed as unfinished"
+ );
+ None
+ }
+ (id, _) => id.clone(),
+ }
+}
+
+/// Where a stopped build's partial manifest is kept, for its error message:
+/// its record, or `out` when the record could not be written, since a
+/// journal the build could not finish may also lack the last upload. A file
+/// already at `out`, which may be a complete manifest, is never replaced;
+/// the partial manifest goes beside it instead.
+fn keep_partial(partial: &BuildResult, out: &Path) -> String {
+ if let Some(id) = recorded_id(partial) {
+ return format!("; the partial manifest is recorded as {id}");
+ }
+ if partial.files_uploaded == 0 {
+ return String::new();
+ }
+ let beside = out.with_extension(PARTIAL_MANIFEST_EXTENSION);
+ let written = write_manifest(&partial.manifest, out, false)
+ .map(|()| out)
+ .or_else(|_| write_manifest(&partial.manifest, &beside, false).map(|()| beside.as_path()));
+ match written {
+ Ok(path) => format!("; the partial manifest is written to {}", path.display()),
+ Err(error) => format!("; the partial manifest could not be kept: {error}"),
+ }
+}
+
/// The output path: the one given, or `.ant` in the current directory.
fn resolve_output(name: Option<&str>, output: Option) -> anyhow::Result {
if let Some(name) = name {
@@ -610,32 +668,99 @@ fn resolve_output(name: Option<&str>, output: Option) -> anyhow::Result
Ok(output.unwrap_or_else(|| PathBuf::from(manifest_filename_for(name))))
}
-fn resolve_download_root(manifest: &Manifest, output: Option) -> anyhow::Result {
+/// Where a download extracts to.
+struct DownloadRoot {
+ path: PathBuf,
+ /// The record marking `path` as this manifest's unfinished default
+ /// download, removed once every entry is in place.
+ record: Option,
+}
+
+/// The directory a download extracts into without `--output`: a new
+/// directory named after the manifest, made in `parent`, the downloads
+/// directory, and recorded in `records` until the download finishes. The
+/// sender chooses that name, so it never picks a hidden directory or one
+/// that already exists, unless an unfinished run of this same download made
+/// it; a manifest without a name needs `--output`. Making the directory is
+/// the check, so nothing can appear there in between.
+fn default_download_root(
+ manifest: &Manifest,
+ parent: &Path,
+ records: Option<&Path>,
+) -> anyhow::Result {
manifest.validate()?;
- Ok(output.unwrap_or_else(|| {
- manifest
- .name
- .as_deref()
- .map(PathBuf::from)
- .unwrap_or_else(|| PathBuf::from("."))
- }))
+ let Some(name) = manifest.name.as_deref() else {
+ anyhow::bail!("this manifest has no name; pass --output to choose where to extract it");
+ };
+ if ant_core::data::manifest::path::is_hidden(name) {
+ anyhow::bail!(
+ "this manifest is named {name:?}, a hidden directory; \
+ pass --output to choose where to extract it"
+ );
+ }
+ std::fs::create_dir_all(parent)
+ .map_err(|e| anyhow::anyhow!("cannot create {}: {e}", parent.display()))?;
+ let path = parent.join(name);
+ match std::fs::create_dir(&path) {
+ Ok(()) => {
+ let record = records.and_then(|records| {
+ start_download_record(records, manifest, &path)
+ .inspect_err(|e| {
+ eprintln!("warning: this download cannot be continued if it stops: {e}")
+ })
+ .ok()
+ });
+ Ok(DownloadRoot { path, record })
+ }
+ Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
+ let unfinished = records
+ .and_then(|records| download_record(records, manifest, &path).ok())
+ .filter(|record| record.is_file());
+ match unfinished {
+ Some(record) => Ok(DownloadRoot {
+ path,
+ record: Some(record),
+ }),
+ None => anyhow::bail!(
+ "{} already exists; choose where to extract with --output, \
+ which may be that directory",
+ path.display()
+ ),
+ }
+ }
+ Err(e) => Err(anyhow::anyhow!("cannot create {}: {e}", path.display())),
+ }
}
-fn partial_record_message(history_id: Option<&str>) -> String {
- history_id
- .map(|id| format!("; the partial manifest is recorded as {id}"))
- .unwrap_or_default()
+/// The record marking `root` as the unfinished default download of
+/// `manifest`, named after both, so no other download or directory matches.
+fn download_record(records: &Path, manifest: &Manifest, root: &Path) -> anyhow::Result {
+ let root = std::fs::canonicalize(root).or_else(|_| std::path::absolute(root))?;
+ let mut key = Sha256::new();
+ key.update(manifest.encode()?);
+ key.update(root.as_os_str().as_encoded_bytes());
+ Ok(records.join(hex::encode(key.finalize())))
}
-fn cancelled_build_error(
- result: &ant_core::data::BuildResult,
- history_id: Option<&str>,
-) -> anyhow::Error {
+/// Record that `root` was just made for `manifest`'s download. The record
+/// holds the directory's path for anyone looking.
+fn start_download_record(
+ records: &Path,
+ manifest: &Manifest,
+ root: &Path,
+) -> anyhow::Result {
+ let record = download_record(records, manifest, root)?;
+ std::fs::create_dir_all(records)?;
+ std::fs::write(&record, root.display().to_string())?;
+ Ok(record)
+}
+
+/// `kept` says where the partial manifest is kept, as [`keep_partial`] does.
+fn cancelled_build_error(result: &ant_core::data::BuildResult, kept: &str) -> anyhow::Error {
anyhow::anyhow!(
- "cancelled after {} of {} file(s){}",
+ "cancelled after {} of {} file(s){kept}",
result.files_uploaded,
result.total_to_upload,
- partial_record_message(history_id),
)
}
@@ -735,7 +860,7 @@ fn report_created(
}
);
if let Some(id) = history_id {
- println!("Recorded as {id} (ant manifest download {id})");
+ println!("Recorded as {id} (ant manifest download {id} --output )");
}
if let Some((link, len)) = link_text {
warn_if_long_link(len);
@@ -759,6 +884,7 @@ fn list_history(json: bool) -> anyhow::Result<()> {
"name": r.manifest.name,
"entries": r.manifest.entries.len(),
"total_size": r.manifest.total_size(),
+ "state": r.state,
"file": r.path.display().to_string(),
})
})
@@ -803,8 +929,12 @@ fn print_history_row(record: &UploadRecord) {
// Drop the trailing " UTC"; the header already says so.
let stamp = format_timestamp(record.recorded_at);
let stamp = stamp.strip_suffix(" UTC").unwrap_or(&stamp);
+ let state = match record.state {
+ RecordState::Complete => "",
+ RecordState::Unfinished => " (unfinished: the files listed were uploaded)",
+ };
println!(
- "{:<23} {:>7} {:>12} {}",
+ "{:<23} {:>7} {:>12} {}{state}",
stamp,
record.manifest.entries.len(),
size,
@@ -913,7 +1043,21 @@ async fn download(
concurrency: NonZeroUsize,
json: bool,
) -> anyhow::Result<()> {
- let output_root = resolve_download_root(manifest, output)?;
+ // Before the default directory is made, so a selection matching nothing
+ // leaves nothing behind.
+ select_entries(manifest, &selection)?;
+ let records = ant_core::config::data_dir()
+ .ok()
+ .map(|dir| dir.join(DOWNLOAD_RECORDS_DIR));
+ let root = match output {
+ Some(path) => DownloadRoot { path, record: None },
+ None => default_download_root(
+ manifest,
+ &ant_core::config::download_dir()?,
+ records.as_deref(),
+ )?,
+ };
+ let output_root = root.path.clone();
let options = ExtractOptions {
output_root: output_root.clone(),
selection,
@@ -928,12 +1072,19 @@ async fn download(
let report = if json {
extract_manifest(client, manifest, &options, None).await?
} else {
+ eprintln!("Downloading into {}", output_root.display());
let (tx, rx) = mpsc::channel(PROGRESS_CHANNEL_CAPACITY);
let ui = tokio::spawn(drive_extract_progress(rx));
let report = extract_manifest(client, manifest, &options, Some(tx)).await;
let _ = ui.await;
report?
};
+ // Every entry is in place, so there is nothing left to continue.
+ if let Some(record) = &root.record {
+ if options.selection.is_empty() && report.failed() == 0 && report.cancelled() == 0 {
+ let _ = std::fs::remove_file(record);
+ }
+ }
if json {
println!(
@@ -941,6 +1092,7 @@ async fn download(
serde_json::to_string_pretty(&json!({
"output": output_root.display().to_string(),
"written": report.written(),
+ "present": report.present(),
"failed": report.failed(),
"cancelled": report.cancelled(),
"entries": report.entries,
@@ -950,13 +1102,15 @@ async fn download(
for e in &report.entries {
match &e.status {
EntryStatus::Written { bytes } => println!("written {} ({bytes} bytes)", e.name),
+ EntryStatus::Present { bytes } => println!("present {} ({bytes} bytes)", e.name),
EntryStatus::Failed { error } => println!("failed {}: {error}", e.name),
EntryStatus::Cancelled => println!("cancelled {}", e.name),
}
}
eprintln!(
- "{} written, {} failed in {:.1}s -> {}",
+ "{} written, {} already present, {} failed in {:.1}s -> {}",
report.written(),
+ report.present(),
report.failed(),
start.elapsed().as_secs_f64(),
output_root.display()
@@ -992,7 +1146,7 @@ fn load_manifest(source: &str) -> anyhow::Result {
}
let dir = default_history_dir()?;
match load_upload(&dir, source)? {
- Some(record) => Ok(record.manifest),
+ Some(record) => Ok(record_manifest(record)),
None => anyhow::bail!(
"{source} is not a manifest file, a manifest link, or a recorded upload id \
(see `ant manifest list`)"
@@ -1000,6 +1154,19 @@ fn load_manifest(source: &str) -> anyhow::Result {
}
}
+/// A recorded upload's manifest, with a reminder of what an unfinished one
+/// holds.
+fn record_manifest(record: UploadRecord) -> Manifest {
+ if record.state == RecordState::Unfinished {
+ eprintln!(
+ "warning: upload {} has not finished; it holds the {} file(s) uploaded so far",
+ record.id,
+ record.manifest.entries.len()
+ );
+ }
+ record.manifest
+}
+
/// Parse the `--torrent-hash` values into one reference, or `None` if none.
fn parse_torrent_hashes(hashes: &[String]) -> anyhow::Result> {
let mut merged: Option = None;
@@ -1176,32 +1343,73 @@ mod tests {
};
assert!(action.execute_offline(false).is_err());
assert!(resolve_output(Some(name), None).is_err());
- assert!(resolve_download_root(&manifest, None).is_err());
+ assert!(default_download_root(&manifest, dir.path(), None).is_err());
}
assert!(!outside.with_extension("ant").exists());
- let valid = Manifest::new(Some("pack".into()));
assert_eq!(
resolve_output(Some("pack"), None).unwrap(),
PathBuf::from("pack.ant")
);
- assert_eq!(
- resolve_download_root(&valid, None).unwrap(),
- PathBuf::from("pack")
- );
let explicit = dir.path().join("chosen/destination");
assert_eq!(
resolve_output(Some("pack"), Some(explicit.clone())).unwrap(),
explicit
);
- assert_eq!(
- resolve_download_root(&valid, Some(explicit.clone())).unwrap(),
- explicit
- );
- assert_eq!(
- resolve_download_root(&Manifest::new(None), None).unwrap(),
- PathBuf::from(".")
- );
+ }
+
+ /// The sender names the manifest, so without --output a download only
+ /// ever extracts into a new directory it has just made in the downloads
+ /// directory, or into one an unfinished run of the same download made.
+ #[test]
+ fn a_download_defaults_only_to_a_new_directory_named_after_the_manifest() {
+ let dir = tempfile::tempdir().unwrap();
+ let parent = dir.path().join("Downloads");
+ let records = dir.path().join("records");
+ let valid = Manifest::new(Some("pack".into()));
+ // A missing downloads directory is made.
+ let first = default_download_root(&valid, &parent, Some(&records)).unwrap();
+ assert_eq!(first.path, parent.join("pack"));
+ assert!(first.path.is_dir());
+ let record = first.record.expect("the new directory is recorded");
+ assert!(record.is_file());
+
+ // The same download again continues into the directory it made.
+ let again = default_download_root(&valid, &parent, Some(&records)).unwrap();
+ assert_eq!(again.path, first.path);
+ assert_eq!(again.record.as_ref(), Some(&record));
+
+ // Another manifest of the same name does not, nor does this one
+ // once its download has finished and the record is gone.
+ let mut other = Manifest::new(Some("pack".into()));
+ other.entries.push(ant_core::data::ManifestEntry {
+ path: Some("a.bin".into()),
+ size: None,
+ sha256: None,
+ source: ContentRef::Public { address: [1; 32] },
+ });
+ std::fs::remove_file(&record).unwrap();
+ for manifest in [&other, &valid] {
+ let error = default_download_root(manifest, &parent, Some(&records))
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("already exists"), "{error}");
+ }
+
+ // Hidden and unnamed manifests need --output, and nothing is made.
+ for manifest in [
+ Manifest::new(Some(".ssh".into())),
+ Manifest::new(Some("\u{200C}.ssh".into())),
+ Manifest::new(Some(".config".into())),
+ Manifest::new(None),
+ ] {
+ let error = default_download_root(&manifest, &parent, Some(&records))
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("--output"), "{error}");
+ }
+ assert!(!parent.join(".ssh").exists());
+ assert!(!parent.join(".config").exists());
}
#[test]
@@ -1215,6 +1423,8 @@ mod tests {
chunks_stored: 3,
storage_cost_atto: 0,
gas_cost_wei: 0,
+ history_id: Some("record-id".into()),
+ history_error: None,
};
for index in 0..3 {
result.manifest.entries.push(ant_core::data::ManifestEntry {
@@ -1225,15 +1435,18 @@ mod tests {
address: [index; 32],
},
});
+ let out = PathBuf::from("unused.ant");
assert_eq!(
- cancelled_build_error(&result, Some("record-id")).to_string(),
+ build_outcome(Ok(result.clone()), &out)
+ .unwrap_err()
+ .to_string(),
"cancelled after 1 of 10 file(s); the partial manifest is recorded as record-id"
);
}
}
#[test]
- fn build_failure_and_cancellation_are_recorded_before_returning_an_error() {
+ fn build_failure_and_cancellation_report_where_the_build_was_recorded() {
let manifest = Manifest {
name: Some("partial".into()),
torrent: None,
@@ -1254,6 +1467,8 @@ mod tests {
chunks_stored: 3,
storage_cost_atto: 12,
gas_cost_wei: 34,
+ history_id: Some("20261019T120000Z-partial".into()),
+ history_error: None,
};
let built = if cancelled {
Ok(partial)
@@ -1264,23 +1479,86 @@ mod tests {
partial: Box::new(partial),
})
};
- let history = tempfile::tempdir().unwrap();
- let error = record_build_result(built, |manifest| {
- Some(record_upload(history.path(), manifest, None).unwrap().id)
- })
- .unwrap_err();
- let listing = list_uploads(history.path()).unwrap();
- assert_eq!(listing.records.len(), 1);
- assert_eq!(listing.records[0].manifest, manifest);
- assert!(error.to_string().contains(&listing.records[0].id));
+ let out = PathBuf::from("unused.ant");
+ let error = build_outcome(built, &out).unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("the partial manifest is recorded as 20261019T120000Z-partial"));
assert!(error.to_string().contains("1 of 2 file(s)"));
if !cancelled {
assert!(error.to_string().contains("later.bin"));
assert!(error.to_string().contains("forced failure"));
}
+ assert!(!out.exists());
}
}
+ /// A build whose record could not be written, as on a full disk, may have
+ /// a journal that lacks its last upload, so the partial manifest goes to
+ /// the output file instead, and no record is claimed.
+ #[test]
+ fn a_stopped_build_that_could_not_be_recorded_is_written_to_the_output() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = Manifest {
+ name: Some("partial".into()),
+ torrent: None,
+ entries: vec![ant_core::data::ManifestEntry {
+ path: Some("completed.bin".into()),
+ size: Some(10),
+ sha256: None,
+ source: ContentRef::Public { address: [1; 32] },
+ }],
+ };
+ let partial = BuildResult {
+ manifest: manifest.clone(),
+ cancelled: false,
+ skipped_symlinks: vec![],
+ files_uploaded: 1,
+ total_to_upload: 2,
+ chunks_stored: 3,
+ storage_cost_atto: 12,
+ gas_cost_wei: 34,
+ history_id: Some("20261019T120000Z-partial".into()),
+ history_error: Some("No space left on device".into()),
+ };
+ let out = dir.path().join("partial.ant");
+ let built = Err(ManifestError::BuildFailed {
+ path: "later.bin".into(),
+ cause: Box::new(ManifestError::Build("No space left on device".into())),
+ partial: Box::new(partial.clone()),
+ });
+ let error = build_outcome(built, &out).unwrap_err().to_string();
+ assert!(!error.contains("recorded as"), "{error}");
+ assert!(
+ error.contains(&format!("written to {}", out.display())),
+ "{error}"
+ );
+ assert_eq!(read_manifest_file(&out).unwrap(), manifest);
+
+ // A file already at the output, which may be a complete manifest, is
+ // never replaced: the partial manifest goes beside it.
+ let complete = Manifest::new(Some("complete".into()));
+ write_manifest_file(&out, &complete, true).unwrap();
+ let built = Err(ManifestError::BuildFailed {
+ path: "later.bin".into(),
+ cause: Box::new(ManifestError::Build("No space left on device".into())),
+ partial: Box::new(partial.clone()),
+ });
+ let error = build_outcome(built, &out).unwrap_err().to_string();
+ let beside = dir.path().join("partial.partial.ant");
+ assert!(
+ error.contains(&format!("written to {}", beside.display())),
+ "{error}"
+ );
+ assert_eq!(read_manifest_file(&out).unwrap(), complete);
+ assert_eq!(read_manifest_file(&beside).unwrap(), manifest);
+
+ // A finished build keeps its manifest in the output anyway; only
+ // the record id is not claimed.
+ let (_, history_id) = build_outcome(Ok(partial), &out).unwrap();
+ assert!(history_id.is_none());
+ }
+
#[test]
fn public_file_spec_parses_with_and_without_path() {
let hex64 = "ab".repeat(32);
diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml
index 1a3b272f..8107daea 100644
--- a/ant-core/Cargo.toml
+++ b/ant-core/Cargo.toml
@@ -20,6 +20,8 @@ blake3 = "1"
base64 = "0.22"
bytes = "1"
hex = "0.4"
+# Checksums for the frames of an upload journal (ADR-0006).
+crc32fast = "1.5"
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
@@ -30,6 +32,8 @@ sha2 = "0.10"
# Portable WebRTC Direct profile shared with ant-node.
saorsa-transport = { version = "0.38.2", default-features = false, features = ["webrtc"] }
thiserror = "2"
+# Full Unicode case folding for the portable manifest path rules (ADR-0006).
+unicase = "2.9"
# NFC normalisation for the portable manifest path rules (ADR-0006).
unicode-normalization = "0.1"
url = "2"
@@ -38,6 +42,9 @@ web-time = "1.1"
# Node management
async-stream = { version = "0.3", optional = true }
axum = { version = "0.8", optional = true }
+# The user's downloads directory, the default for manifest downloads
+# (ADR-0006). Already in the tree through saorsa-core.
+dirs = { version = "6", optional = true }
flate2 = { version = "1", optional = true }
fs2 = { version = "0.4", optional = true }
futures-core = { version = "0.3" }
@@ -128,9 +135,12 @@ libc = "0.2"
# Without this, the musl CI builds fail because the runners lack musl-compatible
# OpenSSL dev libraries. Not needed on Windows (uses schannel via native-tls).
openssl = { version = "0.10", features = ["vendored"] }
+# Directory-relative file operations for containment-safe manifest
+# extraction (ADR-0006). Already in the tree through tempfile.
+rustix = { version = "1.1", features = ["fs"] }
[target.'cfg(windows)'.dependencies]
-windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console", "Win32_System_Threading"] }
+windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Threading"] }
[features]
# Preserve the existing native ant-core API for ordinary consumers. WASM
@@ -141,6 +151,7 @@ native = [
"ant-protocol/logging",
"dep:async-stream",
"dep:axum",
+ "dep:dirs",
"dep:flate2",
"dep:fs2",
"dep:reqwest",
diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs
index d982b2df..9aa74b32 100644
--- a/ant-core/src/config.rs
+++ b/ant-core/src/config.rs
@@ -4,6 +4,10 @@ use std::path::PathBuf;
use crate::data::{CustomNetwork, DevnetManifest, EvmAddress, EvmNetwork, MultiAddr};
use crate::error::{Error, Result};
+/// The downloads directory under the home directory, where the platform
+/// does not say where it is.
+const DOWNLOADS_DIR_NAME: &str = "Downloads";
+
/// Returns the platform-appropriate data directory for ant.
///
/// - Linux: `~/.local/share/ant`
@@ -57,6 +61,19 @@ pub fn log_dir() -> Result {
}
}
+/// Returns the user's downloads directory, where downloads go unless told
+/// otherwise.
+///
+/// - Linux: `XDG_DOWNLOAD_DIR`, or `~/Downloads`
+/// - macOS: `~/Downloads`
+/// - Windows: the Downloads known folder, `%USERPROFILE%\Downloads` unless moved
+pub fn download_dir() -> Result {
+ match dirs::download_dir() {
+ Some(dir) => Ok(dir),
+ None => Ok(home_dir()?.join(DOWNLOADS_DIR_NAME)),
+ }
+}
+
/// Loads bootstrap peers from the platform-appropriate `bootstrap_peers.toml` file.
///
/// Returns `Ok(Some(peers))` if the file exists and parses successfully,
@@ -223,6 +240,11 @@ mod tests {
assert_eq!(dir.file_name().unwrap(), "ant");
}
+ #[test]
+ fn download_dir_is_absolute() {
+ assert!(download_dir().unwrap().is_absolute());
+ }
+
#[test]
fn log_dir_contains_ant() {
let dir = log_dir().unwrap();
diff --git a/ant-core/src/data/client/file/native.rs b/ant-core/src/data/client/file/native.rs
index 052b7b97..0834c238 100644
--- a/ant-core/src/data/client/file/native.rs
+++ b/ant-core/src/data/client/file/native.rs
@@ -3617,6 +3617,27 @@ impl Client {
})
.await
}
+
+ /// Download and decrypt a file into `writer`, with optional progress
+ /// events.
+ ///
+ /// For callers that open the destination themselves, such as a manifest
+ /// extraction that resolves every path from directories it holds open.
+ /// Constant memory (one decrypt batch resident at a time). Nothing is
+ /// renamed or removed here: on an error `writer` may hold part of the
+ /// file, and the caller decides what to do with it.
+ pub async fn file_download_to_writer(
+ &self,
+ data_map: &DataMap,
+ writer: &mut W,
+ progress: Option>,
+ ) -> Result {
+ let peer_count = self.config().close_group_size;
+ self.download_decrypted_chunks(data_map, progress, peer_count, None, None, |bytes| {
+ std::future::ready(writer.write_all(&bytes).map_err(Error::from))
+ })
+ .await
+ }
}
#[cfg(test)]
diff --git a/ant-core/src/data/manifest/build.rs b/ant-core/src/data/manifest/build.rs
index 61060446..af6653f1 100644
--- a/ant-core/src/data/manifest/build.rs
+++ b/ant-core/src/data/manifest/build.rs
@@ -8,24 +8,33 @@
use std::fs;
use std::path::{Path, PathBuf};
-use self_encryption::MIN_ENCRYPTABLE_BYTES;
+use self_encryption::{ChunkInfo, DataMap, MIN_ENCRYPTABLE_BYTES};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
+use xor_name::XorName;
+
+#[cfg(test)]
+use self_encryption::{shrink_data_map, MAX_CHUNK_SIZE};
use crate::data::client::file::{UploadEvent, Visibility};
use crate::data::client::merkle::PaymentMode;
use crate::data::Client;
use super::embed::embeddable_data_map;
+use super::journal::UploadJournal;
use super::path::{check_collisions, validate_component, validate_path, PATH_SEPARATOR};
use super::{
data_map_address, ContentRef, Manifest, ManifestEntry, ManifestError, TorrentReference,
- ADDRESS_LEN, MAX_MANIFEST_ENTRIES,
+ ADDRESS_LEN, MANIFEST_HEADER_LEN, MAX_MANIFEST_BYTES, MAX_MANIFEST_ENTRIES,
+ MAX_PUBLISHED_MAP_CHUNKS, SHA256_LEN,
};
/// Capacity of the per-file upload progress channel.
const UPLOAD_PROGRESS_CAPACITY: usize = 64;
+/// Bytes the entry array's msgpack header can grow by as entries are added:
+/// small counts live in the marker byte, the largest take four more bytes.
+const ENTRY_ARRAY_HEADER_GROWTH: usize = std::mem::size_of::();
/// How a locally uploaded file is referenced from the manifest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -60,6 +69,13 @@ pub struct BuildOptions {
/// Cancels the build between files, or during an upload. Files already
/// uploaded are kept in the partial result.
pub cancel: CancellationToken,
+ /// Upload history directory to record the build in. A journal is
+ /// created there before the first paid upload and each completed upload
+ /// is flushed to it before the next is paid for, so a crash or a forced
+ /// quit loses none of them. When the build ends, complete or not, its
+ /// manifest becomes the record. A build that cannot create the journal
+ /// stops before paying for anything.
+ pub history: Option,
}
/// Progress during [`ManifestBuilder::finish`].
@@ -114,6 +130,16 @@ pub struct BuildResult {
pub storage_cost_atto: u128,
/// Gas paid across all uploads, in wei.
pub gas_cost_wei: u128,
+ /// The upload history record of this build, when it was recorded. A
+ /// build that uploaded nothing before it stopped is not recorded.
+ #[serde(default)]
+ pub history_id: Option,
+ /// Why the record could not be written, when it could not. The journal
+ /// then stays in the history, listed as unfinished, with every upload
+ /// whose entry reached it. When appending to the journal is what failed,
+ /// the last upload is only in `manifest`, so keep that elsewhere.
+ #[serde(default)]
+ pub history_error: Option,
}
struct PendingFile {
@@ -245,9 +271,11 @@ impl<'a> ManifestBuilder<'a> {
}
/// Check everything that could make the finished manifest invalid
- /// before the first paid upload: file sizes, the name, and collisions
- /// among all queued paths and address entries.
- fn preflight(&self) -> Result<(), ManifestError> {
+ /// before the first paid upload: file sizes, the name, collisions among
+ /// all queued paths and address entries, and whether the manifest fits
+ /// [`MAX_MANIFEST_BYTES`] with every file at the largest entry it can
+ /// end as. Returns the size budget the uploads are admitted against.
+ fn preflight(&self) -> Result {
let count = self.pending.len().saturating_add(self.entries.len());
if count > MAX_MANIFEST_ENTRIES {
return Err(ManifestError::TooManyEntries {
@@ -269,6 +297,7 @@ impl<'a> ManifestBuilder<'a> {
));
}
let mut names = Vec::with_capacity(self.pending.len() + self.entries.len());
+ let mut budget = SizeBudget::new(&self.options)?;
for file in &self.pending {
let size = fs::metadata(&file.local)?.len();
if size < MIN_ENCRYPTABLE_BYTES as u64 {
@@ -278,11 +307,15 @@ impl<'a> ManifestBuilder<'a> {
)));
}
names.push(file.path.clone());
+ budget.queue_upload(&file.path)?;
}
for entry in &self.entries {
names.push(entry.effective_name()?);
+ budget.add(entry)?;
}
- check_collisions(names.iter().map(String::as_str)).map_err(ManifestError::Conflict)
+ check_collisions(names.iter().map(String::as_str)).map_err(ManifestError::Conflict)?;
+ budget.ensure_fits()?;
+ Ok(budget)
}
/// Upload every queued file and assemble the manifest.
@@ -297,7 +330,8 @@ impl<'a> ManifestBuilder<'a> {
mut self,
progress: Option>,
) -> Result {
- self.preflight()?;
+ let mut budget = self.preflight()?;
+ let mut journal = self.start_journal()?;
let total = self.pending.len();
let mut files_uploaded = 0;
let mut chunks_stored = 0;
@@ -369,23 +403,43 @@ impl<'a> ManifestBuilder<'a> {
// Keep the upload's usable DataMap before any fallible cost
// parsing or reference optimisation. A later error must not
// discard the file that has just been paid for and uploaded.
- self.entries.push(ManifestEntry {
+ let uploaded = ManifestEntry {
path: Some(file.path.clone()),
size: Some(size),
sha256: Some(result.sha256),
source: ContentRef::Embedded {
data_map: result.data_map.clone(),
},
- });
+ };
+ let counted = budget.uploaded(index, &uploaded);
+ // On disk before anything else can fail or be paid for. If it
+ // cannot be, the build stops rather than pay for uploads a crash
+ // would lose.
+ let journaled = journal
+ .as_mut()
+ .map_or(Ok(()), |journal| journal.append(&uploaded));
+ let storage_cost = parse_atto(&result.storage_cost_atto);
+ self.entries.push(uploaded);
files_uploaded += 1;
chunks_stored += result.chunks_stored;
gas_cost_wei = gas_cost_wei.saturating_add(result.gas_cost_wei);
- match parse_atto(&result.storage_cost_atto) {
- Ok(cost) => storage_cost_atto = storage_cost_atto.saturating_add(cost),
+ if let Ok(cost) = &storage_cost {
+ storage_cost_atto = storage_cost_atto.saturating_add(*cost);
+ }
+ if let Err(error) = journaled {
+ failure = Some((file.path, error));
+ break;
+ }
+ let counted = match counted {
+ Ok(counted) => counted,
Err(error) => {
failure = Some((file.path, error));
break;
}
+ };
+ if let Err(error) = storage_cost {
+ failure = Some((file.path, error));
+ break;
}
let source = match self
@@ -398,11 +452,28 @@ impl<'a> ManifestBuilder<'a> {
break;
}
};
- let reference = source.kind();
- // The entry just appended is always present at this point.
- if let Some(entry) = self.entries.last_mut() {
- entry.source = source;
+ // The entry just appended is always present at this point. A
+ // larger reference, such as an embedded root map, replaces the
+ // uploaded map only if the manifest still fits with it.
+ let Some(entry) = self.entries.last_mut() else {
+ break;
+ };
+ let changed = match budget.offer_source(entry, counted, source) {
+ Ok(changed) => changed,
+ Err(error) => {
+ failure = Some((file.path, error));
+ break;
+ }
+ };
+ // Record the final reference too; the uploaded form is already
+ // safe, so a failure here only stops the build.
+ if let (true, Some(journal)) = (changed, journal.as_mut()) {
+ if let Err(error) = journal.append(entry) {
+ failure = Some((file.path, error));
+ break;
+ }
}
+ let reference = entry.source.kind();
if let Some(tx) = &progress {
let _ = tx
@@ -420,7 +491,29 @@ impl<'a> ManifestBuilder<'a> {
torrent: self.options.torrent.clone(),
entries: std::mem::take(&mut self.entries),
};
- manifest.canonicalize()?;
+ // Everything canonicalising checks was checked before the first
+ // upload, so this should not fail. If it does, the partial result is
+ // still returned, and the journal keeps every completed upload.
+ if let Err(error) = manifest.canonicalize() {
+ failure.get_or_insert((String::new(), error));
+ }
+
+ let stopped_early = cancelled || failure.is_some();
+ let (history_id, history_error) = match journal {
+ None => (None, None),
+ // Nothing was paid for, so there is nothing to keep.
+ Some(journal) if stopped_early && files_uploaded == 0 => {
+ let _ = journal.discard();
+ (None, None)
+ }
+ Some(journal) => {
+ let id = journal.id().to_string();
+ match journal.finish(&manifest) {
+ Ok(record) => (Some(record.id), None),
+ Err(error) => (Some(id), Some(error.to_string())),
+ }
+ }
+ };
let result = BuildResult {
manifest,
@@ -431,6 +524,8 @@ impl<'a> ManifestBuilder<'a> {
chunks_stored,
storage_cost_atto,
gas_cost_wei,
+ history_id,
+ history_error,
};
match failure {
Some((path, cause)) => Err(ManifestError::BuildFailed {
@@ -442,6 +537,25 @@ impl<'a> ManifestBuilder<'a> {
}
}
+ /// Create the build's journal, when it is recorded, holding the entries
+ /// added without uploading. Runs before the first paid upload.
+ fn start_journal(&self) -> Result, ManifestError> {
+ let Some(dir) = &self.options.history else {
+ return Ok(None);
+ };
+ let mut journal = UploadJournal::create(
+ dir,
+ None,
+ self.options.name.as_deref(),
+ self.options.torrent.as_ref(),
+ )?;
+ if let Err(error) = journal.append_all(&self.entries) {
+ let _ = journal.discard();
+ return Err(error);
+ }
+ Ok(Some(journal))
+ }
+
/// Embedded mode always embeds a DataMap, even when the upload was
/// public, and embeds the root map when it is small enough so readers
/// skip the wrapper-record fetches. Compact mode records the public
@@ -511,6 +625,157 @@ fn file_name_utf8(path: &Path) -> Result {
.ok_or_else(|| ManifestError::Build(format!("{} has no UTF-8 file name", path.display())))
}
+/// Keeps a build's manifest within [`MAX_MANIFEST_BYTES`] however its
+/// uploads turn out, so a build that passes preflight never stops for size.
+///
+/// Each queued file is counted at the largest entry it can end as: its own
+/// upload map at the deepest child level, which bounds every map an upload
+/// returns. Once uploaded, a file is counted at its real size, and a larger
+/// reference, such as an embedded root map, is taken only if the manifest
+/// still fits with it. Every partial or complete result can therefore be
+/// encoded and recorded, and nothing is paid for that could not be. A file
+/// that ends as a public address is smaller than counted, so the budget may
+/// refuse a build that would just have fitted, never the reverse.
+struct SizeBudget {
+ /// Encoded bytes the manifest can need, as counted so far.
+ reserved: usize,
+ /// Each queued file's counted entry, in queue order.
+ counted: Vec,
+}
+
+impl SizeBudget {
+ /// The fixed part of a manifest with these options and no entries.
+ fn new(options: &BuildOptions) -> Result {
+ let empty = Manifest {
+ name: options.name.clone(),
+ torrent: options.torrent.clone(),
+ entries: Vec::new(),
+ };
+ Ok(Self {
+ reserved: MANIFEST_HEADER_LEN + encoded_len(&empty)? + ENTRY_ARRAY_HEADER_GROWTH,
+ counted: Vec::new(),
+ })
+ }
+
+ /// Count a queued file at the largest entry it can end as.
+ fn queue_upload(&mut self, path: &str) -> Result<(), ManifestError> {
+ let counted = encoded_len(&largest_upload_entry(path))?;
+ self.counted.push(counted);
+ self.reserved = self.reserved.saturating_add(counted);
+ Ok(())
+ }
+
+ /// Count an entry added without uploading.
+ fn add(&mut self, entry: &ManifestEntry) -> Result<(), ManifestError> {
+ self.reserved = self.reserved.saturating_add(encoded_len(entry)?);
+ Ok(())
+ }
+
+ /// Refuse a manifest that could outgrow the limit.
+ fn ensure_fits(&self) -> Result<(), ManifestError> {
+ fits(self.reserved)
+ }
+
+ /// Count queued file `index` at its uploaded entry from now on, and
+ /// return that entry's size.
+ fn uploaded(&mut self, index: usize, entry: &ManifestEntry) -> Result {
+ let len = encoded_len(entry)?;
+ self.reserved = self.swapped(self.counted_of(index), len);
+ Ok(len)
+ }
+
+ /// Give `entry`, counted at `current` bytes, the reference `source` when
+ /// the manifest still fits with it; otherwise it keeps the one it has.
+ /// Returns whether the reference changed.
+ fn offer_source(
+ &mut self,
+ entry: &mut ManifestEntry,
+ current: usize,
+ source: ContentRef,
+ ) -> Result {
+ let previous = std::mem::replace(&mut entry.source, source);
+ let offered = match encoded_len(entry) {
+ Ok(len) => len,
+ Err(error) => {
+ entry.source = previous;
+ return Err(error);
+ }
+ };
+ let projected = self.swapped(current, offered);
+ if projected <= MAX_MANIFEST_BYTES {
+ self.reserved = projected;
+ Ok(entry.source != previous)
+ } else {
+ entry.source = previous;
+ Ok(false)
+ }
+ }
+
+ fn counted_of(&self, index: usize) -> usize {
+ self.counted.get(index).copied().unwrap_or_default()
+ }
+
+ /// The reserved total with `old` bytes replaced by `new`.
+ fn swapped(&self, old: usize, new: usize) -> usize {
+ self.reserved.saturating_sub(old).saturating_add(new)
+ }
+}
+
+/// Refuse a manifest of `len` encoded bytes when it exceeds the limit.
+fn fits(len: usize) -> Result<(), ManifestError> {
+ if len > MAX_MANIFEST_BYTES {
+ return Err(ManifestError::TooLarge {
+ len,
+ max: MAX_MANIFEST_BYTES,
+ });
+ }
+ Ok(())
+}
+
+/// An uploaded file's entry with the given size and reference.
+fn upload_entry(path: &str, size: u64, source: ContentRef) -> ManifestEntry {
+ ManifestEntry {
+ path: Some(path.to_string()),
+ size: Some(size),
+ sha256: Some([0; SHA256_LEN]),
+ source,
+ }
+}
+
+/// The largest entry an upload of `path` can become, with the largest size,
+/// since the file can grow before it is uploaded.
+fn largest_upload_entry(path: &str) -> ManifestEntry {
+ upload_entry(
+ path,
+ u64::MAX,
+ ContentRef::Embedded {
+ data_map: largest_upload_map(),
+ },
+ )
+}
+
+/// The largest DataMap an upload returns, as the manifest encodes it: its
+/// most chunk records, at the deepest child level. The manifest writes every chunk
+/// record at a fixed size, so only the child level varies.
+fn largest_upload_map() -> DataMap {
+ let infos = (0..MAX_PUBLISHED_MAP_CHUNKS)
+ .map(|index| ChunkInfo {
+ index,
+ dst_hash: XorName::default(),
+ src_hash: XorName::default(),
+ src_size: 0,
+ })
+ .collect();
+ DataMap::with_child(infos, usize::MAX)
+}
+
+/// Bytes `value` takes in a manifest's msgpack encoding.
+fn encoded_len(value: &T) -> Result {
+ rmp_serde::to_vec_named(value)
+ .map(|bytes| bytes.len())
+ .map_err(|e| ManifestError::Encode(e.to_string()))
+}
+
fn parse_atto(value: &str) -> Result {
value
.parse::()
@@ -522,6 +787,154 @@ fn parse_atto(value: &str) -> Result {
mod tests {
use super::*;
+ fn embedded(data_map: DataMap) -> ContentRef {
+ ContentRef::Embedded { data_map }
+ }
+
+ fn root_of(chunks: usize) -> DataMap {
+ DataMap::new(
+ (0..chunks)
+ .map(|index| ChunkInfo {
+ index,
+ dst_hash: XorName::from_content(&index.to_le_bytes()),
+ src_hash: XorName::from_content(&index.to_be_bytes()),
+ src_size: MAX_CHUNK_SIZE,
+ })
+ .collect(),
+ )
+ }
+
+ #[test]
+ fn the_largest_upload_entry_bounds_every_real_one() {
+ let (shrunk, _) = shrink_data_map(root_of(1_000), |_, _| Ok(())).unwrap();
+ assert!(shrunk.is_child());
+ assert_eq!(shrunk.len(), MAX_PUBLISHED_MAP_CHUNKS);
+ let bound = encoded_len(&largest_upload_entry("dir/f.bin")).unwrap();
+ for map in [shrunk, root_of(MAX_PUBLISHED_MAP_CHUNKS)] {
+ let real = encoded_len(&upload_entry("dir/f.bin", 12_345, embedded(map))).unwrap();
+ assert!(real <= bound, "{real} > {bound}");
+ }
+ }
+
+ /// Preflight passes with less than one more file to spare. Every upload
+ /// then returns a shrunk map with a child level and is offered another
+ /// reference, a larger root map or a public address, and the manifest
+ /// never outgrows the limit.
+ #[test]
+ fn a_build_that_passes_preflight_never_stops_for_size() {
+ let path = format!("{}/f.bin", "d".repeat(60_000));
+ let (shrunk, _) = shrink_data_map(root_of(1_000), |_, _| Ok(())).unwrap();
+ let per_file = encoded_len(&largest_upload_entry(&path)).unwrap();
+ for offered in [
+ embedded(root_of(100)),
+ ContentRef::Public {
+ address: [7; ADDRESS_LEN],
+ },
+ ] {
+ let mut budget = SizeBudget::new(&BuildOptions::default()).unwrap();
+ while budget.reserved + per_file <= MAX_MANIFEST_BYTES {
+ budget.queue_upload(&path).unwrap();
+ }
+ budget.ensure_fits().unwrap();
+ for index in 0..budget.counted.len() {
+ let mut entry = upload_entry(&path, 12_345_678_901, embedded(shrunk.clone()));
+ let counted = budget.uploaded(index, &entry).unwrap();
+ assert!(budget.reserved <= MAX_MANIFEST_BYTES);
+ budget
+ .offer_source(&mut entry, counted, offered.clone())
+ .unwrap();
+ assert!(budget.reserved <= MAX_MANIFEST_BYTES);
+ }
+ }
+ }
+
+ /// Many long paths in the default embedded mode: as addresses they would
+ /// fit, embedded they cannot, so the build is refused before any upload.
+ #[test]
+ fn a_build_that_cannot_fit_embedded_is_refused_before_paying() {
+ let path = format!("{}/file.bin", "d".repeat(400));
+ let address_len = encoded_len(&upload_entry(
+ &path,
+ 4_096,
+ ContentRef::Public {
+ address: [0; ADDRESS_LEN],
+ },
+ ))
+ .unwrap();
+ let mut budget = SizeBudget::new(&BuildOptions::default()).unwrap();
+ let files = MAX_MANIFEST_BYTES / address_len - 1;
+ for _ in 0..files {
+ budget.queue_upload(&path).unwrap();
+ }
+ assert!(files * address_len < MAX_MANIFEST_BYTES);
+ assert!(matches!(
+ budget.ensure_fits(),
+ Err(ManifestError::TooLarge { .. })
+ ));
+ }
+
+ #[test]
+ fn the_fixed_part_counts_everything_but_the_entries() {
+ let options = BuildOptions {
+ name: Some("photos".into()),
+ ..BuildOptions::default()
+ };
+ let mut budget = SizeBudget::new(&options).unwrap();
+ let entry = upload_entry("a.bin", 4_096, embedded(root_of(3)));
+ budget.add(&entry).unwrap();
+ let manifest = Manifest {
+ name: options.name,
+ torrent: None,
+ entries: vec![entry],
+ };
+ let actual = manifest.encode().unwrap().len();
+ assert!(actual <= budget.reserved, "{actual} > {}", budget.reserved);
+ assert!(budget.reserved - actual <= ENTRY_ARRAY_HEADER_GROWTH);
+ }
+
+ #[test]
+ fn a_larger_reference_is_taken_only_when_it_fits() {
+ let mut budget = SizeBudget::new(&BuildOptions::default()).unwrap();
+ budget.queue_upload("a.bin").unwrap();
+ let mut entry = upload_entry("a.bin", 4_096, embedded(largest_upload_map()));
+ let counted = budget.uploaded(0, &entry).unwrap();
+
+ // With room, a large root map replaces the uploaded map.
+ assert!(budget
+ .offer_source(&mut entry, counted, embedded(root_of(100)))
+ .unwrap());
+ assert!(
+ matches!(&entry.source, ContentRef::Embedded { data_map } if data_map.len() == 100)
+ );
+ let with_root = encoded_len(&entry).unwrap();
+
+ // Full to the limit: a still larger map is refused and the entry
+ // keeps the one it has; a smaller reference is always taken.
+ budget.reserved = MAX_MANIFEST_BYTES;
+ assert!(!budget
+ .offer_source(&mut entry, with_root, embedded(root_of(200)))
+ .unwrap());
+ assert!(
+ matches!(&entry.source, ContentRef::Embedded { data_map } if data_map.len() == 100)
+ );
+ assert_eq!(budget.reserved, MAX_MANIFEST_BYTES);
+ let public = ContentRef::Public {
+ address: [7; ADDRESS_LEN],
+ };
+ assert!(budget
+ .offer_source(&mut entry, with_root, public.clone())
+ .unwrap());
+ // Offering the same reference again changes nothing.
+ let now = budget.reserved;
+ let current = encoded_len(&entry).unwrap();
+ assert!(!budget
+ .offer_source(&mut entry, current, public.clone())
+ .unwrap());
+ assert_eq!(budget.reserved, now);
+ assert_eq!(entry.source, public);
+ assert!(budget.reserved < MAX_MANIFEST_BYTES);
+ }
+
#[test]
fn file_name_requires_utf8_and_a_name() {
assert_eq!(file_name_utf8(Path::new("a/b.txt")).unwrap(), "b.txt");
diff --git a/ant-core/src/data/manifest/extract.rs b/ant-core/src/data/manifest/extract.rs
index 4a4a5194..9ed52927 100644
--- a/ant-core/src/data/manifest/extract.rs
+++ b/ant-core/src/data/manifest/extract.rs
@@ -2,16 +2,16 @@
//!
//! Containment is a filesystem contract, not a string check. Inside the
//! output root the extractor never follows a symlink, junction or reparse
-//! point: every intermediate directory is created, or confirmed to be a
-//! real directory, without following links, before the download, and the
-//! whole parent chain and the target are checked again, without following
-//! links, immediately before the final rename. The file is written through
-//! a reserved temporary name in its parent. An existing target is a
-//! per-entry failure unless overwrite was requested, and even then only a
-//! regular file is replaced.
-
-use std::fs;
-use std::io;
+//! point, and every name is resolved from directories held open, so
+//! nothing that changes while an entry downloads can redirect where it is
+//! written (see the `output` module). The file is written through a
+//! temporary name in its parent and renamed into place without replacing
+//! anything. A regular file already at a target with the entry's recorded
+//! SHA-256 is kept as that entry, so an extraction that stopped part way can
+//! be run again. Any other existing target is a per-entry failure unless
+//! overwrite was requested, and even then only a regular file is replaced.
+
+use std::io::{self, Write};
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
@@ -19,7 +19,6 @@ use futures::stream::{self, StreamExt};
use self_encryption::DataMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
-use tempfile::Builder as TempBuilder;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
@@ -27,16 +26,10 @@ use crate::data::client::file::DownloadEvent;
use crate::data::error::Error;
use crate::data::Client;
+use super::output::OutputRoot;
use super::path::PATH_SEPARATOR;
use super::{ContentRef, Manifest, ManifestEntry, ManifestError, SHA256_LEN};
-#[cfg(all(test, unix))]
-use std::os::unix::fs::symlink;
-
-/// Read buffer for checking a downloaded file's SHA-256.
-const SHA256_READ_BUFFER: usize = 1024 * 1024;
-/// Prefix of the temporary file an entry is downloaded into.
-const TEMP_PREFIX: &str = ".ant-extract-";
/// Capacity of the per-entry download progress channel.
const DOWNLOAD_PROGRESS_CAPACITY: usize = 64;
@@ -92,6 +85,12 @@ pub enum EntryStatus {
/// Bytes written.
bytes: u64,
},
+ /// Already at its target with the entry's recorded SHA-256, as an
+ /// earlier run left it. Nothing was downloaded or written.
+ Present {
+ /// Bytes of the file.
+ bytes: u64,
+ },
/// Failed; nothing was left at the target.
Failed {
/// The error.
@@ -123,6 +122,11 @@ impl ExtractReport {
self.count(|s| matches!(s, EntryStatus::Written { .. }))
}
+ /// Entries already present.
+ pub fn present(&self) -> usize {
+ self.count(|s| matches!(s, EntryStatus::Present { .. }))
+ }
+
/// Entries that failed.
pub fn failed(&self) -> usize {
self.count(|s| matches!(s, EntryStatus::Failed { .. }))
@@ -187,23 +191,22 @@ pub async fn extract_manifest(
) -> Result {
manifest.validate()?;
let selected = select_entries(manifest, &options.selection)?;
- fs::create_dir_all(&options.output_root)?;
- let total = selected.len();
+ let root = OutputRoot::open(&options.output_root)?;
+ let extraction = Extraction {
+ client,
+ root: &root,
+ options,
+ total: selected.len(),
+ };
+ let extraction = &extraction;
let outcomes: Vec<(usize, EntryOutcome)> = stream::iter(selected.into_iter().enumerate())
.map(|(index, (name, entry))| {
let progress = progress.clone();
async move {
- let status = extract_entry(
- client,
- entry,
- &name,
- index,
- total,
- options,
- progress.as_ref(),
- )
- .await;
+ let status = extraction
+ .extract_entry(entry, &name, index, progress.as_ref())
+ .await;
if let Some(tx) = &progress {
let _ = tx
.send(ExtractEvent::EntryFinished {
@@ -226,125 +229,147 @@ pub async fn extract_manifest(
})
}
-async fn extract_entry(
- client: &Client,
- entry: &ManifestEntry,
- name: &str,
- index: usize,
+/// What every entry of one extraction shares.
+struct Extraction<'a> {
+ client: &'a Client,
+ root: &'a OutputRoot,
+ options: &'a ExtractOptions,
+ /// Number of selected entries.
total: usize,
- options: &ExtractOptions,
- progress: Option<&mpsc::Sender>,
-) -> EntryStatus {
- if options.cancel.is_cancelled() {
- return EntryStatus::Cancelled;
- }
- if let Some(tx) = progress {
- let _ = tx
- .send(ExtractEvent::EntryStarted {
- name: name.to_string(),
- index,
- total,
- })
- .await;
- }
- let work = download_entry(client, entry, name, options, progress);
- tokio::select! {
- _ = options.cancel.cancelled() => EntryStatus::Cancelled,
- result = work => match result {
- Ok(bytes) => EntryStatus::Written { bytes },
- Err(e) => EntryStatus::Failed { error: e.to_string() },
- },
- }
}
-async fn download_entry(
- client: &Client,
- entry: &ManifestEntry,
- name: &str,
- options: &ExtractOptions,
- progress: Option<&mpsc::Sender>,
-) -> Result {
- let target = prepare_target(&options.output_root, name, options.overwrite)?;
- let parent = target
- .parent()
- .ok_or_else(|| ManifestError::Build(format!("{} has no parent", target.display())))?;
-
- // The root map lists every chunk, so its size is the file's real size,
- // unlike the creator's unverified `size` hint. The download would
- // resolve the root anyway.
- let data_map = client
- .data_map_resolve_root(&resolve_data_map(client, &entry.source).await?)
- .await?;
- ensure_space(parent, data_map.original_file_size())?;
-
- let temp = TempBuilder::new()
- .prefix(TEMP_PREFIX)
- .tempfile_in(parent)?
- .into_temp_path();
-
- let download_progress = progress.map(|tx| {
- let (dl_tx, mut dl_rx) = mpsc::channel(DOWNLOAD_PROGRESS_CAPACITY);
- let tx = tx.clone();
- let name = name.to_string();
- tokio::spawn(async move {
- while let Some(event) = dl_rx.recv().await {
- let _ = tx
- .send(ExtractEvent::Download {
- name: name.clone(),
- event,
- })
- .await;
+impl Extraction<'_> {
+ async fn extract_entry(
+ &self,
+ entry: &ManifestEntry,
+ name: &str,
+ index: usize,
+ progress: Option<&mpsc::Sender>,
+ ) -> EntryStatus {
+ if self.options.cancel.is_cancelled() {
+ return EntryStatus::Cancelled;
+ }
+ if let Some(tx) = progress {
+ let _ = tx
+ .send(ExtractEvent::EntryStarted {
+ name: name.to_string(),
+ index,
+ total: self.total,
+ })
+ .await;
+ }
+ let work = self.download_entry(entry, name, progress);
+ tokio::select! {
+ _ = self.options.cancel.cancelled() => EntryStatus::Cancelled,
+ result = work => match result {
+ Ok(status) => status,
+ Err(e) => EntryStatus::Failed { error: e.to_string() },
+ },
+ }
+ }
+
+ async fn download_entry(
+ &self,
+ entry: &ManifestEntry,
+ name: &str,
+ progress: Option<&mpsc::Sender>,
+ ) -> Result {
+ let overwrite = self.options.overwrite;
+ let target = self.root.target(name)?;
+ // Hashing a large file blocks, so keep it off the async workers.
+ // The size, exact or as the creator recorded it, spares reading a
+ // file that cannot be the entry.
+ if let Some(expected) = &entry.sha256 {
+ let size = entry.known_size().or(entry.size);
+ if let Some(bytes) = tokio::task::block_in_place(|| target.identical(expected, size)) {
+ return Ok(EntryStatus::Present { bytes });
}
- });
- dl_tx
- });
-
- let bytes = client
- .file_download_with_progress(&data_map, &temp, download_progress)
- .await?;
-
- // Check the creator's recorded SHA-256 before the file takes its name.
- // On a mismatch the temporary file is dropped, so nothing unverified is
- // left behind.
- if let Some(expected) = entry.sha256 {
- let path = temp.to_path_buf();
- let actual = tokio::task::spawn_blocking(move || file_sha256(&path))
- .await
- .map_err(|e| ManifestError::Build(format!("hashing task failed: {e}")))??;
- if actual != expected {
- return Err(ManifestError::Sha256Mismatch {
- expected: hex::encode(expected),
- actual: hex::encode(actual),
+ }
+ target.check(overwrite)?;
+
+ // The root map lists every chunk, so its size is the file's real
+ // size, unlike the creator's unverified `size` hint. The download
+ // would resolve the root anyway.
+ let client = self.client;
+ let data_map = client
+ .data_map_resolve_root(&resolve_data_map(client, &entry.source).await?)
+ .await?;
+ ensure_space(target.parent_path(), data_map.original_file_size())?;
+
+ let download_progress = progress.map(|tx| {
+ let (dl_tx, mut dl_rx) = mpsc::channel(DOWNLOAD_PROGRESS_CAPACITY);
+ let tx = tx.clone();
+ let name = name.to_string();
+ tokio::spawn(async move {
+ while let Some(event) = dl_rx.recv().await {
+ let _ = tx
+ .send(ExtractEvent::Download {
+ name: name.clone(),
+ event,
+ })
+ .await;
+ }
});
+ dl_tx
+ });
+
+ // Dropping the staged file on any early return, including a
+ // cancellation, removes it.
+ let mut staged = target.stage()?;
+ let mut writer = HashingWriter::new(staged.file()?, entry.sha256.is_some());
+ let bytes = client
+ .file_download_to_writer(&data_map, &mut writer, download_progress)
+ .await?;
+
+ // Check the creator's recorded SHA-256 before the file takes its
+ // name, so nothing unverified is left at the target.
+ if let (Some(expected), Some(actual)) = (entry.sha256, writer.finish()) {
+ if actual != expected {
+ return Err(ManifestError::Sha256Mismatch {
+ expected: hex::encode(expected),
+ actual: hex::encode(actual),
+ });
+ }
}
+ // The commit syncs the file to disk; keep that off the async workers.
+ tokio::task::block_in_place(|| staged.commit(overwrite))?;
+ Ok(EntryStatus::Written { bytes })
}
+}
- // Re-check the parent chain and the target immediately before the
- // rename; either may have changed while the download ran.
- verify_parent_chain(&options.output_root, name)?;
- check_target(&target, options.overwrite)?;
- let persisted = if options.overwrite {
- temp.persist(&target)
- } else {
- temp.persist_noclobber(&target)
- };
- persisted.map_err(|e| ManifestError::Io(e.error))?;
- Ok(bytes)
+/// Writes through to a file, hashing what it writes when asked to, so a
+/// download is checked in the same pass that stores it.
+struct HashingWriter<'a, W> {
+ inner: &'a mut W,
+ hasher: Option,
+}
+
+impl<'a, W: Write> HashingWriter<'a, W> {
+ fn new(inner: &'a mut W, hash: bool) -> Self {
+ Self {
+ inner,
+ hasher: hash.then(Sha256::new),
+ }
+ }
+
+ /// The SHA-256 of everything written, when hashing was asked for.
+ fn finish(self) -> Option<[u8; SHA256_LEN]> {
+ self.hasher.map(|hasher| hasher.finalize().into())
+ }
}
-/// SHA-256 of the file at `path`, read in one streaming pass.
-fn file_sha256(path: &Path) -> io::Result<[u8; SHA256_LEN]> {
- let mut file = fs::File::open(path)?;
- let mut hasher = Sha256::new();
- let mut buffer = vec![0u8; SHA256_READ_BUFFER];
- loop {
- let n = io::Read::read(&mut file, &mut buffer)?;
- if n == 0 {
- break;
+impl Write for HashingWriter<'_, W> {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ let written = self.inner.write(buf)?;
+ if let Some(hasher) = &mut self.hasher {
+ hasher.update(&buf[..written]);
}
- hasher.update(&buffer[..n]);
+ Ok(written)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.inner.flush()
}
- Ok(hasher.finalize().into())
}
/// Fail the entry before downloading when `dir`'s filesystem cannot hold
@@ -371,115 +396,27 @@ async fn resolve_data_map(client: &Client, source: &ContentRef) -> Result Result {
- let mut components = name.split(PATH_SEPARATOR).peekable();
- let mut current = root.to_path_buf();
- while let Some(component) = components.next() {
- current.push(component);
- if components.peek().is_none() {
- check_target(¤t, overwrite)?;
- return Ok(current);
- }
- ensure_real_directory(¤t)?;
- }
- Err(ManifestError::Build("empty effective name".to_string()))
-}
-
-/// Confirm, without creating anything or following links, that every
-/// directory component of `name` beneath `root` is still a real directory.
-fn verify_parent_chain(root: &Path, name: &str) -> Result<(), ManifestError> {
- let mut components = name.split(PATH_SEPARATOR).peekable();
- let mut current = root.to_path_buf();
- while let Some(component) = components.next() {
- if components.peek().is_none() {
- return Ok(());
- }
- current.push(component);
- let meta = fs::symlink_metadata(¤t)?;
- verify_directory(¤t, &meta)?;
- }
- Ok(())
-}
-
-fn ensure_real_directory(path: &Path) -> Result<(), ManifestError> {
- match fs::symlink_metadata(path) {
- Ok(meta) => verify_directory(path, &meta),
- Err(e) if e.kind() == io::ErrorKind::NotFound => {
- match fs::create_dir(path) {
- Ok(()) => {}
- Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
- Err(e) => return Err(e.into()),
- }
- let meta = fs::symlink_metadata(path)?;
- verify_directory(path, &meta)
- }
- Err(e) => Err(e.into()),
- }
-}
-
-fn verify_directory(path: &Path, meta: &fs::Metadata) -> Result<(), ManifestError> {
- if meta.file_type().is_symlink() {
- return Err(ManifestError::Build(format!(
- "{} is a symlink; extraction does not follow links inside the output directory",
- path.display()
- )));
- }
- if !meta.is_dir() {
- return Err(ManifestError::Build(format!(
- "{} exists and is not a directory",
- path.display()
- )));
- }
- Ok(())
-}
-
-fn check_target(target: &Path, overwrite: bool) -> Result<(), ManifestError> {
- match fs::symlink_metadata(target) {
- Ok(meta) => {
- if meta.file_type().is_symlink() {
- return Err(ManifestError::Build(format!(
- "{} is a symlink and will not be replaced",
- target.display()
- )));
- }
- if !overwrite {
- return Err(ManifestError::Build(format!(
- "{} already exists; pass overwrite to replace it",
- target.display()
- )));
- }
- if !meta.is_file() {
- return Err(ManifestError::Build(format!(
- "{} exists and is not a regular file",
- target.display()
- )));
- }
- Ok(())
- }
- Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
- Err(e) => Err(e.into()),
- }
-}
-
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
- fn file_sha256_matches_a_one_shot_digest() {
- let dir = tempfile::tempdir().unwrap();
- // Spans several read buffers, with a ragged tail.
- let data: Vec = (0..(2 * SHA256_READ_BUFFER + 4321))
- .map(|i| (i % 251) as u8)
- .collect();
- let path = dir.path().join("f.bin");
- fs::write(&path, &data).unwrap();
+ fn hashing_writer_matches_a_one_shot_digest_and_writes_everything() {
+ let data: Vec = (0..100_003u32).map(|i| (i % 251) as u8).collect();
+ let mut out = Vec::new();
+ let mut writer = HashingWriter::new(&mut out, true);
+ for piece in data.chunks(4096) {
+ writer.write_all(piece).unwrap();
+ }
let expected: [u8; SHA256_LEN] = Sha256::digest(&data).into();
- assert_eq!(file_sha256(&path).unwrap(), expected);
+ assert_eq!(writer.finish(), Some(expected));
+ assert_eq!(out, data);
+
+ let mut unhashed = Vec::new();
+ let mut writer = HashingWriter::new(&mut unhashed, false);
+ writer.write_all(&data).unwrap();
+ assert_eq!(writer.finish(), None);
}
fn entry(path: &str) -> ManifestEntry {
@@ -517,51 +454,4 @@ mod tests {
assert_eq!(trailing.len(), 2);
assert!(select_entries(&manifest, &["zzz".into()]).is_err());
}
-
- #[test]
- fn prepare_target_creates_directories_and_refuses_existing_file() {
- let root = tempfile::tempdir().unwrap();
- let target = prepare_target(root.path(), "x/y/z.txt", false).unwrap();
- assert_eq!(target, root.path().join("x").join("y").join("z.txt"));
- assert!(root.path().join("x/y").is_dir());
- fs::write(&target, b"old").unwrap();
- assert!(prepare_target(root.path(), "x/y/z.txt", false).is_err());
- assert!(prepare_target(root.path(), "x/y/z.txt", true).is_ok());
- // A directory where a file should go is never replaced.
- assert!(prepare_target(root.path(), "x/y", true).is_err());
- // A file where a directory should go.
- assert!(prepare_target(root.path(), "x/y/z.txt/deeper", false).is_err());
- }
-
- #[cfg(unix)]
- #[test]
- fn prepare_target_never_follows_symlinks_inside_root() {
- let outside = tempfile::tempdir().unwrap();
- let root = tempfile::tempdir().unwrap();
-
- // Pre-existing directory symlink at the first level.
- symlink(outside.path(), root.path().join("safe")).unwrap();
- let err = prepare_target(root.path(), "safe/file.txt", false).unwrap_err();
- assert!(err.to_string().contains("symlink"), "{err}");
- assert!(!outside.path().join("file.txt").exists());
-
- // Deeper: a real directory containing a symlinked directory.
- fs::create_dir(root.path().join("real")).unwrap();
- symlink(outside.path(), root.path().join("real/link")).unwrap();
- assert!(prepare_target(root.path(), "real/link/f", false).is_err());
-
- // A symlink at the target itself, with and without overwrite.
- symlink(outside.path().join("victim"), root.path().join("t.txt")).unwrap();
- assert!(prepare_target(root.path(), "t.txt", false).is_err());
- assert!(prepare_target(root.path(), "t.txt", true).is_err());
- assert!(!outside.path().join("victim").exists());
-
- // A directory swapped for a symlink after preparation is caught by
- // the pre-rename check.
- prepare_target(root.path(), "late/file", false).unwrap();
- fs::remove_dir(root.path().join("late")).unwrap();
- symlink(outside.path(), root.path().join("late")).unwrap();
- assert!(verify_parent_chain(root.path(), "late/file").is_err());
- assert!(verify_parent_chain(root.path(), "real/f").is_ok());
- }
}
diff --git a/ant-core/src/data/manifest/file.rs b/ant-core/src/data/manifest/file.rs
index 4f04163d..d65f883b 100644
--- a/ant-core/src/data/manifest/file.rs
+++ b/ant-core/src/data/manifest/file.rs
@@ -21,6 +21,11 @@ pub fn manifest_filename_for(name: Option<&str>) -> String {
/// Read and decode a `.ant` file, refusing oversized files before reading.
pub fn read_manifest_file(path: &Path) -> Result {
+ Manifest::decode(&read_manifest_bytes(path)?)
+}
+
+/// The bytes of a `.ant` file, refusing an oversized file before reading.
+pub(super) fn read_manifest_bytes(path: &Path) -> Result, ManifestError> {
let len = fs::metadata(path)?.len();
if len > MAX_MANIFEST_BYTES as u64 {
return Err(ManifestError::TooLarge {
@@ -28,7 +33,7 @@ pub fn read_manifest_file(path: &Path) -> Result {
max: MAX_MANIFEST_BYTES,
});
}
- Manifest::decode(&fs::read(path)?)
+ Ok(fs::read(path)?)
}
/// Encode and write a manifest atomically. Refuses to replace an existing
diff --git a/ant-core/src/data/manifest/history.rs b/ant-core/src/data/manifest/history.rs
index a8e5f0c1..c077da0e 100644
--- a/ant-core/src/data/manifest/history.rs
+++ b/ant-core/src/data/manifest/history.rs
@@ -8,8 +8,10 @@
//! When an id is already taken, which happens when uploads with the same label
//! finish within one second, `-2`, `-3` and so on are appended. Records list
//! newest first, and within one second by that suffix compared as a number.
-//! Nothing else is stored, so the history directory is just a folder of
-//! manifests that any manifest tool can read.
+//! Apart from the journal of a manifest build that has not finished
+//! (`.ant.partial`, see the `journal` module), nothing else is stored, so
+//! the history directory is just a folder of manifests that any manifest
+//! tool can read. That journal is listed as an unfinished record.
use std::fs;
use std::path::{Path, PathBuf};
@@ -17,11 +19,16 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
-use super::file::{read_manifest_file, write_manifest_file};
-use super::{Manifest, ManifestError, MANIFEST_EXTENSION};
+use super::file::read_manifest_bytes;
+use super::journal::{journal_path, read_journal, UploadJournal, JOURNAL_SUFFIX};
+use super::path::{
+ check_collisions, portable_component, validate_component, validate_path, PathError,
+ PATH_SEPARATOR,
+};
+use super::{Manifest, ManifestEntry, ManifestError, MANIFEST_EXTENSION};
#[cfg(test)]
-use super::{ContentRef, ManifestEntry};
+use super::{ContentRef, MANIFEST_FORMAT_VERSION, MANIFEST_MAGIC};
/// Directory under the data directory that holds upload records.
pub const UPLOAD_HISTORY_DIR: &str = "uploads";
@@ -78,14 +85,33 @@ const FEBRUARY: u32 = 2;
pub struct UploadRecord {
/// Filename stem: `-`.
pub id: String,
- /// Where the manifest lives.
+ /// Where the manifest lives: the `.ant` record, or the journal of an
+ /// upload that has not finished.
pub path: PathBuf,
- /// When it was recorded, seconds since the Unix epoch.
+ /// When it was recorded, seconds since the Unix epoch. For a manifest
+ /// build, when the build started.
pub recorded_at: u64,
/// The label part of the id.
pub label: String,
- /// The manifest itself.
+ /// The manifest itself. For an unfinished upload, every entry that
+ /// reached the disk so far.
pub manifest: Manifest,
+ /// Whether the upload finished.
+ #[serde(default)]
+ pub state: RecordState,
+}
+
+/// Whether a recorded upload finished.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RecordState {
+ /// The upload ended and its record was written, complete or partial.
+ #[default]
+ Complete,
+ /// A manifest build that has not finished: it is still running, or it
+ /// stopped on a crash or a forced quit. Its entries are every upload it
+ /// completed so far.
+ Unfinished,
}
/// Result of listing a history directory.
@@ -104,46 +130,85 @@ pub fn default_history_dir() -> Result {
}
/// Write `manifest` into `dir` as a new record and return it.
+///
+/// The id is claimed through a journal, as a build claims its own, so a
+/// record never takes the id of a build that is still running. If the
+/// record itself cannot be written, the journal stays and the upload is
+/// listed as unfinished.
pub fn record_upload(
dir: &Path,
manifest: &Manifest,
label: Option<&str>,
) -> Result {
- fs::create_dir_all(dir)?;
+ manifest.validate()?;
+ let mut journal = UploadJournal::create(
+ dir,
+ label,
+ manifest.name.as_deref(),
+ manifest.torrent.as_ref(),
+ )?;
+ journal.append_all(&manifest.entries)?;
+ journal.finish(manifest)
+}
+
+/// The ids to try, in order, for a record made now with `label`.
+pub(super) struct NewIds {
+ /// When the record is made, seconds since the Unix epoch.
+ pub(super) recorded_at: u64,
+ /// The sanitised label.
+ pub(super) label: String,
+ base: String,
+ attempt: u32,
+}
+
+impl Iterator for NewIds {
+ type Item = String;
+
+ fn next(&mut self) -> Option {
+ if self.attempt >= MAX_ID_ATTEMPTS {
+ return None;
+ }
+ let id = if self.attempt == 0 {
+ self.base.clone()
+ } else {
+ format!("{}{ID_SEPARATOR}{}", self.base, self.attempt + 1)
+ };
+ self.attempt += 1;
+ Some(id)
+ }
+}
+
+/// The ids for a record made now: `-`, then `-2`, `-3`
+/// and so on.
+pub(super) fn new_ids(label: Option<&str>) -> NewIds {
let recorded_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
- let label = sanitize_label(label.or(manifest.name.as_deref()));
+ let label = sanitize_label(label);
let base = format!(
"{}{ID_SEPARATOR}{label}",
format_compact_timestamp(recorded_at)
);
- for attempt in 0..MAX_ID_ATTEMPTS {
- let id = if attempt == 0 {
- base.clone()
- } else {
- format!("{base}{ID_SEPARATOR}{}", attempt + 1)
- };
- let path = dir.join(format!("{id}.{MANIFEST_EXTENSION}"));
- match write_manifest_file(&path, manifest, false) {
- Ok(()) => {
- return Ok(UploadRecord {
- id,
- path,
- recorded_at,
- label,
- manifest: manifest.clone(),
- })
- }
- Err(ManifestError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
- Err(e) => return Err(e),
- }
+ NewIds {
+ recorded_at,
+ label,
+ base,
+ attempt: 0,
}
- Err(ManifestError::Build(format!(
+}
+
+/// Where the finished record with `id` lives in `dir`.
+pub(super) fn record_path(dir: &Path, id: &str) -> PathBuf {
+ dir.join(format!("{id}.{MANIFEST_EXTENSION}"))
+}
+
+/// The error when every candidate id is taken.
+pub(super) fn no_free_id(dir: &Path) -> ManifestError {
+ ManifestError::Build(format!(
"could not find a free record id after {MAX_ID_ATTEMPTS} attempts in {}",
dir.display()
- )))
+ ))
}
/// List every record in `dir`, newest first. A missing directory is an
@@ -157,10 +222,32 @@ pub fn list_uploads(dir: &Path) -> Result {
};
for entry in entries {
let path = entry?.path();
- if path.extension().and_then(|e| e.to_str()) != Some(MANIFEST_EXTENSION) {
- continue;
- }
- match load_record(&path) {
+ let journal_id = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .and_then(|name| name.strip_suffix(JOURNAL_SUFFIX));
+ let loaded = match journal_id {
+ // A journal whose record exists is left over from a build that
+ // stopped between writing the record and removing the journal.
+ Some(id) if record_path(dir, id).is_file() => continue,
+ Some(id) => match load_journal(&path, id) {
+ // Its build finished since the directory was read.
+ Err(ManifestError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ // One holding nothing paid for nothing, and one whose build
+ // has just written its record is that record.
+ Ok(record)
+ if record.manifest.entries.is_empty() || record_path(dir, id).is_file() =>
+ {
+ continue
+ }
+ loaded => loaded,
+ },
+ None if path.extension().and_then(|e| e.to_str()) == Some(MANIFEST_EXTENSION) => {
+ load_record(&path)
+ }
+ None => continue,
+ };
+ match loaded {
Ok(record) => listing.records.push(record),
Err(e) => listing.unreadable.push((path, e.to_string())),
}
@@ -192,15 +279,54 @@ pub fn load_upload(dir: &Path, id: &str) -> Result, Manifes
if id.is_empty() || id.contains(['/', '\\']) {
return Ok(None);
}
- let path = dir.join(format!("{id}.{MANIFEST_EXTENSION}"));
- match fs::symlink_metadata(&path) {
- Ok(meta) if meta.is_file() => load_record(&path).map(Some),
- Ok(_) => Ok(None),
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
+ let path = record_path(dir, id);
+ if is_regular_file(&path)? {
+ return load_record(&path).map(Some);
+ }
+ let journal = journal_path(dir, id);
+ if is_regular_file(&journal)? {
+ return load_journal(&journal, id).map(Some);
+ }
+ Ok(None)
+}
+
+/// Whether `path` is a regular file, not following a link.
+fn is_regular_file(path: &Path) -> Result {
+ match fs::symlink_metadata(path) {
+ Ok(meta) => Ok(meta.is_file()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
+/// An unfinished build's journal as a record.
+fn load_journal(path: &Path, id: &str) -> Result {
+ let manifest = read_journal(path)?;
+ let (recorded_at, label) = match parse_id(id) {
+ Some(parsed) => parsed,
+ None => (modified_secs(path)?, id.to_string()),
+ };
+ Ok(UploadRecord {
+ id: id.to_string(),
+ path: path.to_path_buf(),
+ recorded_at,
+ label,
+ manifest,
+ state: RecordState::Unfinished,
+ })
+}
+
+/// When `path` was last modified, seconds since the Unix epoch, or zero if
+/// unknown.
+fn modified_secs(path: &Path) -> Result {
+ Ok(fs::metadata(path)?
+ .modified()
+ .ok()
+ .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
+ .map(|d| d.as_secs())
+ .unwrap_or_default())
+}
+
/// `YYYY-MM-DD HH:MM:SS UTC` for display.
pub fn format_timestamp(unix_secs: u64) -> String {
let (year, month, day, hour, minute, second) = split_unix(unix_secs);
@@ -212,18 +338,10 @@ fn load_record(path: &Path) -> Result {
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| ManifestError::Build(format!("{} has no UTF-8 name", path.display())))?;
- let manifest = read_manifest_file(path)?;
+ let manifest = read_record(path)?;
let (recorded_at, label) = match parse_id(stem) {
Some(parsed) => parsed,
- None => (
- fs::metadata(path)?
- .modified()
- .ok()
- .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
- .map(|d| d.as_secs())
- .unwrap_or_default(),
- stem.to_string(),
- ),
+ None => (modified_secs(path)?, stem.to_string()),
};
Ok(UploadRecord {
id: stem.to_string(),
@@ -231,9 +349,88 @@ fn load_record(path: &Path) -> Result {
recorded_at,
label,
manifest,
+ state: RecordState::Complete,
})
}
+/// Read a record, repairing names that break the current portable path
+/// rules instead of refusing it.
+fn read_record(path: &Path) -> Result {
+ repair_names(Manifest::decode_unvalidated(&read_manifest_bytes(path)?)?)
+}
+
+/// Bring a record written under looser path rules in line with the current
+/// ones, the way a new record's names are repaired: an invalid name or path
+/// component is made portable, a path that cannot be is dropped so the entry
+/// extracts under its content address, of two entries whose names now
+/// collide the later loses its path, and a file whose name is now that of a
+/// directory other entries are in loses its path rather than they theirs. A
+/// client update so never makes a recorded upload unreachable. A manifest
+/// that is already valid is returned unchanged.
+pub(super) fn repair_names(mut manifest: Manifest) -> Result {
+ if manifest.validate().is_ok() {
+ return Ok(manifest);
+ }
+ if let Some(name) = &manifest.name {
+ if validate_component(name).is_err() {
+ manifest.name = portable_component(name);
+ }
+ }
+ for entry in &mut manifest.entries {
+ if let Some(path) = &entry.path {
+ if validate_path(path).is_err() {
+ entry.path = portable_path(path);
+ }
+ }
+ }
+ let mut names = manifest
+ .entries
+ .iter()
+ .map(ManifestEntry::effective_name)
+ .collect::, _>>()?;
+ // Each round drops a path or a duplicate entry, so this ends.
+ loop {
+ let index = match check_collisions(names.iter().map(String::as_str)) {
+ Ok(()) => break,
+ Err(PathError::Duplicate(name)) => last_named(&names, &name),
+ // The file in the way gives way, or, when it is already under
+ // its content address, the entry in the directory does.
+ Err(PathError::PrefixConflict { dir, file }) => last_named(&names, &dir)
+ .filter(|&index| manifest.entries[index].path.is_some())
+ .or_else(|| last_named(&names, &file)),
+ Err(other) => return Err(ManifestError::Conflict(other)),
+ };
+ let Some(index) = index else {
+ break;
+ };
+ if manifest.entries[index].path.take().is_some() {
+ names[index] = manifest.entries[index].effective_name()?;
+ } else {
+ // Already under its content address: the same content twice.
+ manifest.entries.remove(index);
+ names.remove(index);
+ }
+ }
+ manifest.validate()?;
+ Ok(manifest)
+}
+
+/// The index of the last entry named `name`.
+fn last_named(names: &[String], name: &str) -> Option {
+ names.iter().rposition(|candidate| candidate == name)
+}
+
+/// `path` with every component made portable, or `None` when a component
+/// has nothing usable left or the result breaks the rules some other way.
+fn portable_path(path: &str) -> Option {
+ let components = path
+ .split(PATH_SEPARATOR)
+ .map(portable_component)
+ .collect::>>()?;
+ let repaired = components.join(&PATH_SEPARATOR.to_string());
+ validate_path(&repaired).is_ok().then_some(repaired)
+}
+
fn sanitize_label(label: Option<&str>) -> String {
let cleaned: String = label
.unwrap_or(DEFAULT_LABEL)
@@ -267,7 +464,8 @@ fn parse_id(id: &str) -> Option<(u64, String)> {
let (stamp, label) = id.split_at_checked(TIMESTAMP_LEN)?;
let label = label.strip_prefix(ID_SEPARATOR)?;
let bytes = stamp.as_bytes();
- if bytes[8] != b'T' || bytes[15] != b'Z' {
+ // ASCII, so the fields below are sliced on character boundaries.
+ if !stamp.is_ascii() || bytes[8] != b'T' || bytes[15] != b'Z' {
return None;
}
let num = |range: std::ops::Range| stamp[range].parse::().ok();
@@ -359,6 +557,85 @@ mod tests {
}
}
+ /// A record an older client wrote under looser rules still loads: its
+ /// names are repaired instead of the record being refused, and no entry
+ /// is lost.
+ #[test]
+ fn records_written_under_looser_rules_load_repaired() {
+ let dir = tempfile::tempdir().unwrap();
+ let entry = |path: &str, seed: u8| ManifestEntry {
+ path: Some(path.into()),
+ size: Some(1),
+ sha256: None,
+ source: ContentRef::Public {
+ address: [seed; 32],
+ },
+ };
+ let old = Manifest {
+ name: Some("old\u{85}set".into()),
+ torrent: None,
+ entries: vec![
+ entry("ab", 1),
+ entry("a\u{200C}b", 2),
+ entry("next\u{85}line.txt", 3),
+ entry("strasse", 4),
+ entry("stra\u{1e9e}e/x", 5),
+ ],
+ };
+ assert!(old.validate().is_err());
+ let mut bytes = MANIFEST_MAGIC.to_vec();
+ bytes.push(MANIFEST_FORMAT_VERSION);
+ bytes.extend(rmp_serde::to_vec_named(&old).unwrap());
+ let id = "20261001T000000Z-old";
+ fs::write(record_path(dir.path(), id), &bytes).unwrap();
+
+ let record = load_upload(dir.path(), id).unwrap().unwrap();
+ record.manifest.validate().unwrap();
+ assert_eq!(record.manifest.name.as_deref(), Some("old_set"));
+ assert_eq!(record.manifest.entries.len(), old.entries.len());
+ let paths: Vec<_> = record
+ .manifest
+ .entries
+ .iter()
+ .map(|e| e.path.clone())
+ .collect();
+ assert!(paths.contains(&Some("next_line.txt".into())));
+ assert!(paths.contains(&Some("ab".into())));
+ assert!(paths.contains(&Some("stra\u{1e9e}e/x".into())));
+ // The later of the duplicates, and the file in the way of the
+ // directory, extract under their addresses.
+ assert_eq!(paths.iter().filter(|p| p.is_none()).count(), 2);
+ let listing = list_uploads(dir.path()).unwrap();
+ assert_eq!(listing.records.len(), 1, "{:?}", listing.unreadable);
+ }
+
+ /// A file named like a directory, apart from a character the filesystem
+ /// ignores: the one file gives way, not every entry in the directory.
+ #[test]
+ fn a_file_in_the_way_of_a_directory_loses_its_path_not_the_entries_in_it() {
+ let entry = |path: String, seed: u8| ManifestEntry {
+ path: Some(path),
+ size: Some(1),
+ sha256: None,
+ source: ContentRef::Public {
+ address: [seed; 32],
+ },
+ };
+ let mut entries = vec![entry("docs\u{200C}".into(), 0)];
+ entries.extend((1..=100).map(|seed| entry(format!("docs/{seed}.bin"), seed)));
+ let old = Manifest {
+ name: None,
+ torrent: None,
+ entries,
+ };
+ assert!(old.validate().is_err());
+ let repaired = repair_names(old).unwrap();
+ repaired.validate().unwrap();
+ assert_eq!(repaired.entries.len(), 101);
+ assert!(repaired.entries[0].path.is_none());
+ assert!(repaired.entries[1..].iter().all(|e| e.path.is_some()));
+ }
+
#[test]
fn timestamps_round_trip_through_ids() {
for secs in [0u64, 951_782_400, 1_759_759_391, 4_102_444_800] {
@@ -373,6 +650,7 @@ mod tests {
assert_eq!(format_compact_timestamp(1_759_759_391), "20251006T140311Z");
assert!(parse_id("not-an-id").is_none());
assert!(parse_id("20261332T000000Z-x").is_none());
+ assert!(parse_id("202\u{e9}001T000000Z-x").is_none());
}
#[test]
diff --git a/ant-core/src/data/manifest/journal.rs b/ant-core/src/data/manifest/journal.rs
new file mode 100644
index 00000000..8e773026
--- /dev/null
+++ b/ant-core/src/data/manifest/journal.rs
@@ -0,0 +1,517 @@
+//! Crash-safe progress of a manifest build, kept in the upload history.
+//!
+//! A build pays for its uploads one file at a time, so it must not lose a
+//! paid upload when the process stops before the build ends: a crash, a
+//! kill, a power cut or a second Ctrl-C. Before the first upload the builder
+//! creates `.ant.partial` in the history directory. As each upload
+//! completes it appends the entry and flushes it to disk before the next
+//! upload is paid for. When the build ends the journal becomes the ordinary
+//! `.ant` record and is removed. Until then, and for good if the build
+//! stops without finishing, the history lists it as an unfinished upload,
+//! and every entry that reached the disk can be shown, linked, exported and
+//! downloaded like any other record.
+//!
+//! The file is magic bytes, a version byte, and frames: each a little-endian
+//! `u32` length, the CRC-32 of the payload, and the payload. The first
+//! payload carries the manifest's name and torrent reference; every other
+//! payload is one entry, msgpack-encoded as in a manifest. Reading stops at
+//! the first frame that is incomplete or fails its check, which is where a
+//! write was cut short. A later entry with the same effective name replaces
+//! an earlier one, so an entry is recorded as soon as its upload completes
+//! and again once its reference is final. A journal is read up to its last
+//! whole frame, so one still being written reads as the uploads so far.
+//!
+//! On Unix the journal is readable by its owner only, as a record is, since
+//! it holds the DataMaps of private uploads.
+
+use std::collections::HashMap;
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, Read, Write};
+use std::path::{Path, PathBuf};
+
+#[cfg(unix)]
+use std::os::unix::fs::OpenOptionsExt;
+
+use serde::{Deserialize, Serialize};
+
+use super::file::write_manifest_file;
+use super::history::{
+ new_ids, no_free_id, record_path, record_upload, repair_names, RecordState, UploadRecord,
+};
+use super::output::unless_unflushable;
+use super::{Manifest, ManifestEntry, ManifestError, TorrentReference, MAX_MANIFEST_BYTES};
+
+#[cfg(test)]
+use super::history::{list_uploads, load_upload};
+#[cfg(test)]
+use super::ContentRef;
+#[cfg(all(test, unix))]
+use std::os::unix::fs::PermissionsExt;
+
+/// Suffix of a journal's file name after its record id.
+pub(super) const JOURNAL_SUFFIX: &str = ".ant.partial";
+/// Magic bytes that open a journal.
+const JOURNAL_MAGIC: [u8; 4] = [0xC1, b'A', b'N', b'J'];
+/// The journal layout this client writes and reads.
+const JOURNAL_FORMAT_VERSION: u8 = 1;
+/// Bytes before the first frame: the magic and the version.
+const JOURNAL_HEADER_LEN: usize = JOURNAL_MAGIC.len() + 1;
+/// Bytes of a frame's length field, and of its checksum field.
+const FRAME_FIELD_LEN: usize = std::mem::size_of::();
+/// Largest journal read. Every entry is recorded at most twice, and each
+/// frame's header is small next to an entry, so a journal of any manifest
+/// within the limit stays under three times that limit.
+const MAX_JOURNAL_BYTES: u64 = 3 * MAX_MANIFEST_BYTES as u64;
+/// Permissions of a new journal on Unix: read and write for its owner only.
+#[cfg(unix)]
+const JOURNAL_MODE: u32 = 0o600;
+
+/// The first frame: what a manifest carries besides its entries.
+#[derive(Serialize, Deserialize)]
+struct JournalHeader {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ name: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ torrent: Option,
+}
+
+/// A running build's journal, held open until the build ends.
+pub(super) struct UploadJournal {
+ dir: PathBuf,
+ id: String,
+ path: PathBuf,
+ recorded_at: u64,
+ label: String,
+ file: File,
+}
+
+impl UploadJournal {
+ /// Start a journal in the history directory `dir` for a manifest with
+ /// this name and torrent reference, under an id made from `label`, or
+ /// from `name` when there is no label.
+ ///
+ /// Holding an id's journal is what claims the id: a record is only ever
+ /// written by the holder of its journal. So a record found once the
+ /// journal is created was written before, the id is skipped, and no two
+ /// writers can take one id.
+ pub(super) fn create(
+ dir: &Path,
+ label: Option<&str>,
+ name: Option<&str>,
+ torrent: Option<&TorrentReference>,
+ ) -> Result {
+ fs::create_dir_all(dir)?;
+ let mut ids = new_ids(label.or(name));
+ while let Some(id) = ids.next() {
+ let path = journal_path(dir, &id);
+ let mut options = OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ options.mode(JOURNAL_MODE);
+ let file = match options.open(&path) {
+ Ok(file) => file,
+ Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(e) => return Err(e.into()),
+ };
+ let journal = Self {
+ dir: dir.to_path_buf(),
+ id,
+ path,
+ recorded_at: ids.recorded_at,
+ label: ids.label.clone(),
+ file,
+ };
+ if record_path(dir, &journal.id).exists() {
+ let _ = journal.discard();
+ continue;
+ }
+ let mut journal = journal;
+ let header = JournalHeader {
+ name: name.map(str::to_owned),
+ torrent: torrent.cloned(),
+ };
+ return match journal.start(&header) {
+ Ok(()) => Ok(journal),
+ Err(error) => {
+ let _ = journal.discard();
+ Err(error)
+ }
+ };
+ }
+ Err(no_free_id(dir))
+ }
+
+ /// The record id the build will be recorded under.
+ pub(super) fn id(&self) -> &str {
+ &self.id
+ }
+
+ /// Record `entry` and flush it to disk before returning.
+ pub(super) fn append(&mut self, entry: &ManifestEntry) -> Result<(), ManifestError> {
+ self.write_frame(entry)?;
+ self.flush()
+ }
+
+ /// Record every entry, flushing them to disk once at the end.
+ pub(super) fn append_all(&mut self, entries: &[ManifestEntry]) -> Result<(), ManifestError> {
+ for entry in entries {
+ self.write_frame(entry)?;
+ }
+ self.flush()
+ }
+
+ /// Write `manifest` as the build's record and remove the journal.
+ pub(super) fn finish(self, manifest: &Manifest) -> Result {
+ let path = record_path(&self.dir, &self.id);
+ let record = match write_manifest_file(&path, manifest, false) {
+ Ok(()) => UploadRecord {
+ id: self.id.clone(),
+ path,
+ recorded_at: self.recorded_at,
+ label: self.label.clone(),
+ manifest: manifest.clone(),
+ state: RecordState::Complete,
+ },
+ // A client that writes records without claiming them first took
+ // the id after this journal did.
+ Err(ManifestError::Io(e)) if e.kind() == io::ErrorKind::AlreadyExists => {
+ record_upload(&self.dir, manifest, Some(&self.label))?
+ }
+ Err(e) => return Err(e),
+ };
+ // Remove the journal only once the record's name is on disk, or a
+ // power cut could lose both. A journal left beside its record is
+ // ignored by listings.
+ if sync_directory(&self.dir).is_ok() {
+ let _ = self.discard();
+ }
+ Ok(record)
+ }
+
+ /// Remove the journal of a build that recorded nothing worth keeping.
+ pub(super) fn discard(self) -> Result<(), ManifestError> {
+ let Self { file, path, .. } = self;
+ drop(file);
+ fs::remove_file(path).map_err(Into::into)
+ }
+
+ /// Write the opening and the header frame, and make the journal and its
+ /// name durable.
+ fn start(&mut self, header: &JournalHeader) -> Result<(), ManifestError> {
+ self.file.write_all(&opening())?;
+ self.write_frame(header)?;
+ self.flush()?;
+ sync_directory(&self.dir)
+ }
+
+ /// Flush what was written to disk, where the filesystem can.
+ fn flush(&mut self) -> Result<(), ManifestError> {
+ unless_unflushable(self.file.sync_data()).map_err(Into::into)
+ }
+
+ /// Write one frame, not yet flushed to disk.
+ fn write_frame(&mut self, value: &T) -> Result<(), ManifestError> {
+ let payload =
+ rmp_serde::to_vec_named(value).map_err(|e| ManifestError::Encode(e.to_string()))?;
+ let len = u32::try_from(payload.len())
+ .map_err(|_| ManifestError::Encode("journal entry is too large".to_string()))?;
+ let mut frame = Vec::with_capacity(2 * FRAME_FIELD_LEN + payload.len());
+ frame.extend_from_slice(&len.to_le_bytes());
+ frame.extend_from_slice(&crc32fast::hash(&payload).to_le_bytes());
+ frame.extend_from_slice(&payload);
+ self.file.write_all(&frame).map_err(Into::into)
+ }
+}
+
+/// The bytes a journal opens with: the magic and the version.
+fn opening() -> [u8; JOURNAL_HEADER_LEN] {
+ let mut opening = [JOURNAL_FORMAT_VERSION; JOURNAL_HEADER_LEN];
+ opening[..JOURNAL_MAGIC.len()].copy_from_slice(&JOURNAL_MAGIC);
+ opening
+}
+
+/// Where the journal of the record `id` lives in `dir`.
+pub(super) fn journal_path(dir: &Path, id: &str) -> PathBuf {
+ dir.join(format!("{id}{JOURNAL_SUFFIX}"))
+}
+
+/// The manifest a journal holds: every entry that reached the disk.
+pub(super) fn read_journal(path: &Path) -> Result {
+ let mut file = File::open(path)?;
+ let len = file.metadata()?.len();
+ if len > MAX_JOURNAL_BYTES {
+ return Err(ManifestError::TooLarge {
+ len: usize::try_from(len).unwrap_or(usize::MAX),
+ max: usize::try_from(MAX_JOURNAL_BYTES).unwrap_or(usize::MAX),
+ });
+ }
+ let mut bytes = Vec::new();
+ file.read_to_end(&mut bytes)?;
+ parse_journal(&bytes)
+}
+
+/// The manifest in a journal's bytes, up to the first frame that is cut
+/// short or damaged.
+fn parse_journal(bytes: &[u8]) -> Result {
+ let not_a_journal = || ManifestError::Decode("not an upload journal".to_string());
+ let Some((opening, frames)) = bytes.split_at_checked(JOURNAL_HEADER_LEN) else {
+ // Cut short before its opening was whole: it holds nothing yet.
+ return if self::opening().starts_with(bytes) {
+ Ok(Manifest::default())
+ } else {
+ Err(not_a_journal())
+ };
+ };
+ if opening[..JOURNAL_MAGIC.len()] != JOURNAL_MAGIC {
+ return Err(not_a_journal());
+ }
+ let version = opening[JOURNAL_MAGIC.len()];
+ if version != JOURNAL_FORMAT_VERSION {
+ return Err(ManifestError::UnsupportedVersion(version));
+ }
+ let mut frames = Frames { rest: frames };
+ // A journal cut short before its header holds no entries either.
+ let header = match frames.next() {
+ Some(payload) => rmp_serde::from_slice(payload)
+ .map_err(|e| ManifestError::Decode(format!("journal header: {e}")))?,
+ None => JournalHeader {
+ name: None,
+ torrent: None,
+ },
+ };
+ let mut entries: Vec = Vec::new();
+ let mut positions: HashMap = HashMap::new();
+ for payload in frames {
+ // An entry that does not decode was damaged, so nothing after it is
+ // trusted either.
+ let Ok(entry) = rmp_serde::from_slice::(payload) else {
+ break;
+ };
+ let name = entry.effective_name()?;
+ match positions.get(&name) {
+ Some(&position) => entries[position] = entry,
+ None => {
+ positions.insert(name, entries.len());
+ entries.push(entry);
+ }
+ }
+ }
+ // Repaired, not refused, if a later client tightened the rules.
+ repair_names(Manifest {
+ name: header.name,
+ torrent: header.torrent,
+ entries,
+ })
+}
+
+/// The payloads of whole, checked frames, ending at the first that is not.
+struct Frames<'a> {
+ rest: &'a [u8],
+}
+
+impl<'a> Iterator for Frames<'a> {
+ type Item = &'a [u8];
+
+ fn next(&mut self) -> Option<&'a [u8]> {
+ let frame = self.split_frame();
+ if frame.is_none() {
+ self.rest = &[];
+ }
+ frame
+ }
+}
+
+impl<'a> Frames<'a> {
+ fn split_frame(&mut self) -> Option<&'a [u8]> {
+ let (len, rest) = self.rest.split_first_chunk::()?;
+ let (checksum, rest) = rest.split_first_chunk::()?;
+ let len = usize::try_from(u32::from_le_bytes(*len)).ok()?;
+ let (payload, rest) = rest.split_at_checked(len)?;
+ if crc32fast::hash(payload) != u32::from_le_bytes(*checksum) {
+ return None;
+ }
+ self.rest = rest;
+ Some(payload)
+ }
+}
+
+/// Make a newly created name in `dir` survive a power cut. Windows keeps
+/// directory entries durable without being asked.
+fn sync_directory(dir: &Path) -> Result<(), ManifestError> {
+ #[cfg(unix)]
+ unless_unflushable(File::open(dir)?.sync_all())?;
+ #[cfg(not(unix))]
+ let _ = dir;
+ Ok(())
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used, clippy::expect_used)]
+mod tests {
+ use super::*;
+
+ fn entry(path: &str, seed: u8) -> ManifestEntry {
+ ManifestEntry {
+ path: Some(path.into()),
+ size: Some(u64::from(seed)),
+ sha256: None,
+ source: ContentRef::Public {
+ address: [seed; 32],
+ },
+ }
+ }
+
+ #[test]
+ fn a_journal_is_an_unfinished_record_of_what_reached_the_disk() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut journal = UploadJournal::create(dir.path(), None, Some("photos"), None).unwrap();
+ let id = journal.id().to_string();
+ journal.append(&entry("a.bin", 1)).unwrap();
+ journal.append(&entry("b.bin", 2)).unwrap();
+ // The same name again: the final reference replaces the first.
+ journal.append(&entry("a.bin", 3)).unwrap();
+
+ // While the build runs, and once it stops without finishing, the
+ // record is unfinished and holds every upload so far.
+ let running = load_upload(dir.path(), &id).unwrap().unwrap();
+ drop(journal);
+ let listing = list_uploads(dir.path()).unwrap();
+ assert_eq!(listing.records.len(), 1, "{:?}", listing.unreadable);
+ let record = &listing.records[0];
+ assert_eq!(*record, running);
+ assert_eq!(record.id, id);
+ assert_eq!(record.state, RecordState::Unfinished);
+ assert_eq!(record.manifest.name.as_deref(), Some("photos"));
+ assert_eq!(
+ record.manifest.entries,
+ vec![entry("a.bin", 3), entry("b.bin", 2)]
+ );
+ }
+
+ #[test]
+ fn a_write_cut_short_keeps_every_entry_before_it() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut journal = UploadJournal::create(dir.path(), None, None, None).unwrap();
+ let id = journal.id().to_string();
+ journal.append(&entry("a.bin", 1)).unwrap();
+ journal.append(&entry("b.bin", 2)).unwrap();
+ drop(journal);
+ let path = journal_path(dir.path(), &id);
+ let full = fs::read(&path).unwrap();
+
+ // Torn in the middle of the last frame.
+ fs::write(&path, &full[..full.len() - 3]).unwrap();
+ let manifest = read_journal(&path).unwrap();
+ assert_eq!(manifest.entries, vec![entry("a.bin", 1)]);
+
+ // Garbage where the last frame was: its checksum fails.
+ let mut damaged = full.clone();
+ let last = damaged.len() - 1;
+ damaged[last] ^= 0xff;
+ fs::write(&path, &damaged).unwrap();
+ assert_eq!(
+ read_journal(&path).unwrap().entries,
+ vec![entry("a.bin", 1)]
+ );
+
+ // Cut before the header: an unfinished record with nothing in it.
+ fs::write(&path, &full[..JOURNAL_HEADER_LEN + 2]).unwrap();
+ assert!(read_journal(&path).unwrap().entries.is_empty());
+
+ fs::write(&path, b"something else").unwrap();
+ assert!(read_journal(&path).is_err());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn a_journal_is_readable_by_its_owner_only() {
+ let dir = tempfile::tempdir().unwrap();
+ let journal = UploadJournal::create(dir.path(), None, None, None).unwrap();
+ let mode = fs::metadata(journal_path(dir.path(), journal.id()))
+ .unwrap()
+ .permissions()
+ .mode();
+ assert_eq!(mode & 0o777, JOURNAL_MODE);
+ }
+
+ #[test]
+ fn finishing_writes_the_record_and_removes_the_journal() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut journal = UploadJournal::create(dir.path(), None, Some("set"), None).unwrap();
+ let id = journal.id().to_string();
+ journal.append(&entry("a.bin", 1)).unwrap();
+ let manifest = Manifest {
+ name: Some("set".into()),
+ torrent: None,
+ entries: vec![entry("a.bin", 1)],
+ };
+ let record = journal.finish(&manifest).unwrap();
+ assert_eq!(record.id, id);
+ assert_eq!(record.state, RecordState::Complete);
+ assert!(!journal_path(dir.path(), &id).exists());
+ let loaded = load_upload(dir.path(), &id).unwrap().unwrap();
+ assert_eq!(loaded.manifest, manifest);
+ assert_eq!(loaded.state, RecordState::Complete);
+ }
+
+ #[test]
+ fn journals_and_records_never_share_an_id() {
+ let dir = tempfile::tempdir().unwrap();
+ let journal = UploadJournal::create(dir.path(), None, Some("same"), None).unwrap();
+ let manifest = Manifest {
+ name: Some("same".into()),
+ torrent: None,
+ entries: vec![entry("a.bin", 1)],
+ };
+ // A record made in the same second with the same label skips the
+ // id the running build holds.
+ let record = record_upload(dir.path(), &manifest, None).unwrap();
+ assert_ne!(record.id, journal.id());
+ let other = UploadJournal::create(dir.path(), None, Some("same"), None).unwrap();
+ assert_ne!(other.id(), journal.id());
+ assert_ne!(other.id(), record.id);
+ }
+
+ #[test]
+ fn an_empty_journal_is_not_listed_and_a_record_leaves_no_journal() {
+ let dir = tempfile::tempdir().unwrap();
+ // Journals cut short at creation, before their opening was whole:
+ // nothing was paid for, so there is nothing to list.
+ fs::write(journal_path(dir.path(), "20261001T000000Z-empty"), b"").unwrap();
+ fs::write(
+ journal_path(dir.path(), "20261001T000001Z-opening"),
+ &opening()[..JOURNAL_MAGIC.len()],
+ )
+ .unwrap();
+ let listing = list_uploads(dir.path()).unwrap();
+ assert!(listing.records.is_empty(), "{:?}", listing.records);
+ assert!(listing.unreadable.is_empty(), "{:?}", listing.unreadable);
+
+ let manifest = Manifest {
+ name: None,
+ torrent: None,
+ entries: vec![entry("a.bin", 1)],
+ };
+ let record = record_upload(dir.path(), &manifest, Some("single")).unwrap();
+ assert!(!journal_path(dir.path(), &record.id).exists());
+ assert_eq!(list_uploads(dir.path()).unwrap().records.len(), 1);
+ }
+
+ #[test]
+ fn a_leftover_journal_beside_its_record_is_not_listed() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut journal = UploadJournal::create(dir.path(), None, None, None).unwrap();
+ let id = journal.id().to_string();
+ journal.append(&entry("a.bin", 1)).unwrap();
+ drop(journal);
+ let manifest = Manifest {
+ name: None,
+ torrent: None,
+ entries: vec![entry("a.bin", 1)],
+ };
+ write_manifest_file(&record_path(dir.path(), &id), &manifest, false).unwrap();
+ let listing = list_uploads(dir.path()).unwrap();
+ assert_eq!(listing.records.len(), 1);
+ assert_eq!(listing.records[0].state, RecordState::Complete);
+ }
+}
diff --git a/ant-core/src/data/manifest/mod.rs b/ant-core/src/data/manifest/mod.rs
index 944134d8..f3684f84 100644
--- a/ant-core/src/data/manifest/mod.rs
+++ b/ant-core/src/data/manifest/mod.rs
@@ -46,6 +46,10 @@ pub mod extract;
pub mod file;
#[cfg(feature = "native")]
pub mod history;
+#[cfg(feature = "native")]
+mod journal;
+#[cfg(feature = "native")]
+mod output;
#[cfg(feature = "native")]
pub use self::embed::embeddable_data_map;
@@ -289,7 +293,8 @@ pub enum ManifestError {
#[cfg(feature = "native")]
#[error("manifest build failed at {path:?}: {cause}")]
BuildFailed {
- /// Manifest path being processed when the failure occurred.
+ /// Manifest path being processed when the failure occurred, or
+ /// empty when the failure concerned the manifest as a whole.
path: String,
/// The underlying failure.
#[source]
@@ -538,6 +543,15 @@ impl Manifest {
/// limits and the portable path rules. The structure, including the
/// entry count, is checked before any entry is decoded.
pub fn decode(bytes: &[u8]) -> Result {
+ let manifest = Self::decode_unvalidated(bytes)?;
+ manifest.validate()?;
+ Ok(manifest)
+ }
+
+ /// [`Manifest::decode`] without the final validation of names, paths and
+ /// collisions, for a reader that repairs them instead. Every size, depth,
+ /// structure and entry-count limit still applies.
+ pub(crate) fn decode_unvalidated(bytes: &[u8]) -> Result {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(ManifestError::TooLarge {
len: bytes.len(),
@@ -555,10 +569,7 @@ impl Manifest {
wire::check_structure(body)?;
let mut deserializer = rmp_serde::Deserializer::from_read_ref(body);
deserializer.set_max_depth(MAX_DECODE_DEPTH);
- let manifest = Manifest::deserialize(&mut deserializer)
- .map_err(|e| ManifestError::Decode(e.to_string()))?;
- manifest.validate()?;
- Ok(manifest)
+ Manifest::deserialize(&mut deserializer).map_err(|e| ManifestError::Decode(e.to_string()))
}
/// Whether `bytes` start with the manifest magic. Cheap classification
diff --git a/ant-core/src/data/manifest/output/mod.rs b/ant-core/src/data/manifest/output/mod.rs
new file mode 100644
index 00000000..0233d446
--- /dev/null
+++ b/ant-core/src/data/manifest/output/mod.rs
@@ -0,0 +1,475 @@
+//! Containment for extraction: every name beneath the output root is
+//! resolved from directories held open, and nothing beneath the root is
+//! followed through a link.
+//!
+//! On Unix each directory is a file descriptor and every operation is
+//! relative to one (`openat`, `mkdirat`, `renameat`), opened without
+//! following links. Once a directory is open, renaming it or replacing any
+//! path to it with a symlink cannot redirect a later write.
+//!
+//! On Windows each directory from the output root down is held open
+//! without delete sharing. While those handles are open, Windows refuses to
+//! rename or delete any directory on the chain. Directories beneath the
+//! root are opened as the reparse point itself, so a junction or symlink is
+//! detected, not followed. Operations still go by path, so a concurrent
+//! process that can write in the output root can turn a directory this
+//! extraction created, while it is still empty, into a junction; containing
+//! that would take handle-relative opens through the native API.
+//!
+//! A file is written under a temporary name in its parent and renamed into
+//! place without replacing anything. An existing name is replaced only when
+//! overwrite was requested and it is a regular file. A regular file already
+//! at a target with the entry's SHA-256 is that entry, as an earlier run
+//! left it, and is kept.
+
+use std::fs::{self, File};
+use std::io::{self, BufReader};
+use std::path::{Path, PathBuf};
+
+use sha2::{Digest, Sha256};
+
+use super::path::PATH_SEPARATOR;
+use super::{ManifestError, SHA256_LEN};
+
+#[cfg(test)]
+use std::io::Write;
+#[cfg(all(test, unix))]
+use std::os::unix::fs::{symlink, PermissionsExt};
+#[cfg(all(test, unix))]
+use std::process::Command;
+
+#[cfg(unix)]
+mod unix;
+#[cfg(unix)]
+use unix as platform;
+#[cfg(windows)]
+mod windows;
+#[cfg(windows)]
+use windows as platform;
+
+use platform::Dir;
+
+/// Prefix of the temporary name an entry is written under.
+const TEMP_PREFIX: &str = ".ant-extract-";
+/// Temporary names tried before giving up on finding a free one.
+const MAX_TEMP_ATTEMPTS: u32 = 16;
+/// Bytes read at a time when hashing a file already at a target.
+const HASH_READ_BYTES: usize = 1024 * 1024;
+
+/// What a name in a directory is, read without following a link.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum EntryKind {
+ File,
+ Directory,
+ Symlink,
+ Other,
+}
+
+/// The output root of one extraction, held open while it runs.
+pub(super) struct OutputRoot {
+ dir: Dir,
+}
+
+impl OutputRoot {
+ /// Create `root` if it is missing and open it. The root itself may be a
+ /// link, since it is the caller's choice; nothing beneath it is followed.
+ pub(super) fn open(root: &Path) -> Result {
+ fs::create_dir_all(root)?;
+ Ok(Self {
+ dir: Dir::open_root(root)?,
+ })
+ }
+
+ /// Resolve `name` beneath the root, creating each of its directories or
+ /// confirming it is a real one, without following links.
+ pub(super) fn target(&self, name: &str) -> Result, ManifestError> {
+ let mut components: Vec<&str> = name.split(PATH_SEPARATOR).collect();
+ let file_name = components
+ .pop()
+ .filter(|component| !component.is_empty())
+ .ok_or_else(|| ManifestError::Build("empty effective name".to_string()))?;
+ let mut chain: Vec = Vec::new();
+ for component in components {
+ let child = chain.last().unwrap_or(&self.dir).open_child(component)?;
+ if !platform::HOLD_ANCESTORS {
+ chain.clear();
+ }
+ chain.push(child);
+ }
+ Ok(Target {
+ root: self,
+ chain,
+ file_name: file_name.to_string(),
+ })
+ }
+}
+
+/// Where one entry is written: its parent directory, held open, and the
+/// file name within it.
+pub(super) struct Target<'r> {
+ root: &'r OutputRoot,
+ /// The directories below the root, or only the parent where holding
+ /// the parent alone keeps the chain fixed.
+ chain: Vec,
+ file_name: String,
+}
+
+impl Target<'_> {
+ fn dir(&self) -> &Dir {
+ self.chain.last().unwrap_or(&self.root.dir)
+ }
+
+ /// The target's path, for messages.
+ pub(super) fn path(&self) -> PathBuf {
+ self.dir().display().join(&self.file_name)
+ }
+
+ /// The parent directory's path, for messages and free-space checks.
+ pub(super) fn parent_path(&self) -> &Path {
+ self.dir().display()
+ }
+
+ /// Refuse, before any download, a target that could not be written.
+ pub(super) fn check(&self, overwrite: bool) -> Result<(), ManifestError> {
+ self.existing(overwrite).map(|_| ())
+ }
+
+ /// The length of the regular file already at the target when its
+ /// SHA-256 is `expected` and, where `size` is known, its length is
+ /// `size`: the entry is there already and needs no download. Anything
+ /// else, including a file that cannot be read, is not the entry and is
+ /// left to [`Target::check`]. The length is compared first, so a large
+ /// file that is not the entry is not read.
+ pub(super) fn identical(&self, expected: &[u8; SHA256_LEN], size: Option) -> Option {
+ let file = self.dir().open_file(&self.file_name).ok()?;
+ // Checked on the handle that is read, whatever the name holds now.
+ let len = file.metadata().ok().filter(|meta| meta.is_file())?.len();
+ if size.is_some_and(|size| size != len) {
+ return None;
+ }
+ let mut hasher = Sha256::new();
+ io::copy(
+ &mut BufReader::with_capacity(HASH_READ_BYTES, file),
+ &mut hasher,
+ )
+ .ok()?;
+ (hasher.finalize().as_slice() == expected).then_some(len)
+ }
+
+ /// Create the temporary file the entry is downloaded into.
+ pub(super) fn stage(&self) -> Result, ManifestError> {
+ let mut last_error = None;
+ for _ in 0..MAX_TEMP_ATTEMPTS {
+ let temp_name = format!("{TEMP_PREFIX}{:016x}", rand::random::());
+ match self.dir().create_new(&temp_name) {
+ Ok(file) => {
+ return Ok(Staged {
+ target: self,
+ temp_name: Some(temp_name),
+ file: Some(file),
+ })
+ }
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+ last_error = Some(error);
+ }
+ Err(error) => return Err(error.into()),
+ }
+ }
+ Err(last_error
+ .unwrap_or_else(|| io::Error::from(io::ErrorKind::AlreadyExists))
+ .into())
+ }
+
+ /// Whether the target exists and may be replaced. Refuses a link, any
+ /// existing name without overwrite, and anything but a regular file.
+ fn existing(&self, overwrite: bool) -> Result {
+ let Some(kind) = self.dir().entry(&self.file_name)? else {
+ return Ok(false);
+ };
+ let path = self.path();
+ match kind {
+ EntryKind::Symlink => Err(ManifestError::Build(format!(
+ "{} is a symlink and will not be replaced",
+ path.display()
+ ))),
+ _ if !overwrite => Err(ManifestError::Build(format!(
+ "{} already exists; pass overwrite to replace it",
+ path.display()
+ ))),
+ EntryKind::File => Ok(true),
+ EntryKind::Directory | EntryKind::Other => Err(ManifestError::Build(format!(
+ "{} exists and is not a regular file",
+ path.display()
+ ))),
+ }
+ }
+}
+
+/// An entry's temporary file. Dropping it without [`Staged::commit`]
+/// removes the file, so a failed or cancelled entry leaves nothing behind.
+pub(super) struct Staged<'t> {
+ target: &'t Target<'t>,
+ /// The temporary name, until the file takes the target's name.
+ temp_name: Option,
+ /// Open until the commit closes it.
+ file: Option,
+}
+
+impl Staged<'_> {
+ /// The open temporary file to write the entry into.
+ pub(super) fn file(&mut self) -> Result<&mut File, ManifestError> {
+ self.file
+ .as_mut()
+ .ok_or_else(|| ManifestError::Build("the temporary file is closed".to_string()))
+ }
+
+ /// Make the file durable and give it the target's name.
+ pub(super) fn commit(mut self, overwrite: bool) -> Result<(), ManifestError> {
+ if let Some(file) = self.file.take() {
+ unless_unflushable(file.sync_all())?;
+ }
+ let target = self.target;
+ let dir = target.dir();
+ let temp_name = self
+ .temp_name
+ .clone()
+ .ok_or_else(|| ManifestError::Build("the entry was already committed".to_string()))?;
+ if target.existing(overwrite)? {
+ dir.rename_replace(&temp_name, &target.file_name)?;
+ } else {
+ dir.rename_noreplace(&temp_name, &target.file_name)
+ .map_err(|error| match error.kind() {
+ io::ErrorKind::AlreadyExists => ManifestError::Build(format!(
+ "{} appeared while the entry downloaded; it was not replaced",
+ target.path().display()
+ )),
+ _ => error.into(),
+ })?;
+ }
+ // The file is in place, so nothing from here on fails the entry. A
+ // rename done as a hard link may have left the temporary name too.
+ let _ = dir.remove_file(&temp_name);
+ self.temp_name = None;
+ Ok(())
+ }
+}
+
+impl Drop for Staged<'_> {
+ fn drop(&mut self) {
+ // Close before removing: Windows cannot remove an open file that was
+ // opened without delete sharing.
+ drop(self.file.take());
+ if let Some(temp_name) = &self.temp_name {
+ let _ = self.target.dir().remove_file(temp_name);
+ }
+ }
+}
+
+/// The result of flushing a file to disk, with a refusal from a filesystem
+/// that cannot flush taken as success. Such a filesystem, as an SMB share
+/// on macOS refuses the full flush, keeps the file as it keeps any write.
+pub(super) fn unless_unflushable(result: io::Result<()>) -> io::Result<()> {
+ match result {
+ Err(error) if platform::flush_unsupported(&error) => Ok(()),
+ result => result,
+ }
+}
+
+/// The error for a link inside the output root.
+fn not_followed(path: &Path) -> ManifestError {
+ ManifestError::Build(format!(
+ "{} is a symlink; extraction does not follow links inside the output directory",
+ path.display()
+ ))
+}
+
+/// The error for a non-directory where a directory is needed.
+fn not_a_directory(path: &Path) -> ManifestError {
+ ManifestError::Build(format!("{} exists and is not a directory", path.display()))
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used, clippy::expect_used)]
+mod tests {
+ use super::*;
+
+ fn write_entry(root: &OutputRoot, name: &str, bytes: &[u8], overwrite: bool) {
+ let target = root.target(name).unwrap();
+ target.check(overwrite).unwrap();
+ let mut staged = target.stage().unwrap();
+ staged.file().unwrap().write_all(bytes).unwrap();
+ staged.commit(overwrite).unwrap();
+ }
+
+ fn temp_files(dir: &Path) -> Vec {
+ fs::read_dir(dir)
+ .unwrap()
+ .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
+ .filter(|name| name.starts_with(TEMP_PREFIX))
+ .collect()
+ }
+
+ #[test]
+ fn writes_beneath_the_root_and_refuses_what_exists() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = OutputRoot::open(&dir.path().join("out")).unwrap();
+ write_entry(&root, "x/y/z.txt", b"first", false);
+ let path = dir.path().join("out/x/y/z.txt");
+ assert_eq!(fs::read(&path).unwrap(), b"first");
+
+ let target = root.target("x/y/z.txt").unwrap();
+ assert_eq!(target.path(), path);
+ assert!(target.check(false).is_err());
+ assert!(target.check(true).is_ok());
+ // A directory where a file should go is never replaced.
+ assert!(root.target("x/y").unwrap().check(true).is_err());
+ // A file where a directory should go.
+ assert!(root.target("x/y/z.txt/deeper").is_err());
+ assert!(temp_files(&dir.path().join("out/x/y")).is_empty());
+ }
+
+ #[test]
+ fn overwrite_replaces_only_a_pre_existing_regular_file() {
+ let dir = tempfile::tempdir().unwrap();
+ fs::write(dir.path().join("old.txt"), b"old").unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+ assert!(root.target("old.txt").unwrap().check(false).is_err());
+ write_entry(&root, "old.txt", b"new", true);
+ assert_eq!(fs::read(dir.path().join("old.txt")).unwrap(), b"new");
+ }
+
+ #[test]
+ fn a_file_with_the_entry_hash_and_size_is_kept() {
+ let dir = tempfile::tempdir().unwrap();
+ fs::write(dir.path().join("kept.bin"), b"earlier run").unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+ let hash: [u8; SHA256_LEN] = Sha256::digest(b"earlier run").into();
+ let target = root.target("kept.bin").unwrap();
+ assert_eq!(target.identical(&hash, Some(11)), Some(11));
+ assert_eq!(target.identical(&hash, None), Some(11));
+ assert_eq!(target.identical(&hash, Some(12)), None);
+ assert_eq!(target.identical(&[0; SHA256_LEN], None), None);
+ assert_eq!(
+ fs::read(dir.path().join("kept.bin")).unwrap(),
+ b"earlier run"
+ );
+ let missing = root.target("missing.bin").unwrap();
+ assert_eq!(missing.identical(&hash, None), None);
+ }
+
+ /// A file that cannot be read is not the entry, and overwrite still
+ /// replaces it. A FIFO at the target is not the entry either, and
+ /// looking at it does not block.
+ #[cfg(unix)]
+ #[test]
+ fn an_unreadable_target_or_a_fifo_is_not_the_entry() {
+ let dir = tempfile::tempdir().unwrap();
+ let locked = dir.path().join("locked.bin");
+ fs::write(&locked, b"earlier run").unwrap();
+ fs::set_permissions(&locked, fs::Permissions::from_mode(0o200)).unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+ let hash: [u8; SHA256_LEN] = Sha256::digest(b"earlier run").into();
+ let target = root.target("locked.bin").unwrap();
+ assert_eq!(target.identical(&hash, None), None);
+ write_entry(&root, "locked.bin", b"new", true);
+ fs::set_permissions(&locked, fs::Permissions::from_mode(0o600)).unwrap();
+ assert_eq!(fs::read(&locked).unwrap(), b"new");
+
+ let made = Command::new("mkfifo")
+ .arg(dir.path().join("fifo.bin"))
+ .status()
+ .unwrap();
+ assert!(made.success());
+ let fifo = root.target("fifo.bin").unwrap();
+ assert_eq!(fifo.identical(&hash, None), None);
+ }
+
+ #[test]
+ fn a_staged_file_that_is_not_committed_is_removed() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+ let target = root.target("a/b.bin").unwrap();
+ let mut staged = target.stage().unwrap();
+ staged.file().unwrap().write_all(b"partial").unwrap();
+ assert_eq!(temp_files(&dir.path().join("a")).len(), 1);
+ drop(staged);
+ assert!(temp_files(&dir.path().join("a")).is_empty());
+ assert!(!dir.path().join("a/b.bin").exists());
+ }
+
+ #[test]
+ fn a_target_that_appears_during_the_download_is_not_replaced() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+ let target = root.target("late.bin").unwrap();
+ target.check(false).unwrap();
+ let mut staged = target.stage().unwrap();
+ staged.file().unwrap().write_all(b"ours").unwrap();
+ fs::write(dir.path().join("late.bin"), b"theirs").unwrap();
+ let error = staged.commit(false).unwrap_err();
+ assert!(error.to_string().contains("already exists"), "{error}");
+ assert_eq!(fs::read(dir.path().join("late.bin")).unwrap(), b"theirs");
+ assert!(temp_files(dir.path()).is_empty());
+
+ // The rename itself refuses to replace, for a name that appears
+ // after the commit's own check.
+ fs::write(dir.path().join("mine.tmp"), b"ours").unwrap();
+ let error = root
+ .dir
+ .rename_noreplace("mine.tmp", "late.bin")
+ .unwrap_err();
+ assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
+ assert_eq!(fs::read(dir.path().join("late.bin")).unwrap(), b"theirs");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn links_inside_the_root_are_never_followed() {
+ let outside = tempfile::tempdir().unwrap();
+ let dir = tempfile::tempdir().unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+
+ // A pre-existing directory symlink at the first level.
+ symlink(outside.path(), dir.path().join("safe")).unwrap();
+ let error = root.target("safe/file.txt").err().unwrap();
+ assert!(error.to_string().contains("symlink"), "{error}");
+
+ // Deeper: a real directory containing a symlinked directory.
+ fs::create_dir(dir.path().join("real")).unwrap();
+ symlink(outside.path(), dir.path().join("real/link")).unwrap();
+ assert!(root.target("real/link/f").is_err());
+
+ // A symlink at the target itself, with and without overwrite.
+ symlink(outside.path().join("victim"), dir.path().join("t.txt")).unwrap();
+ let target = root.target("t.txt").unwrap();
+ assert_eq!(target.identical(&[0; SHA256_LEN], None), None);
+ assert!(target.check(false).is_err());
+ assert!(target.check(true).is_err());
+ assert!(!outside.path().join("victim").exists());
+ assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
+ }
+
+ /// The window the path-based design left open: a directory checked as
+ /// real is swapped for a symlink to somewhere else while the download
+ /// runs. The open directory is still where the file lands.
+ #[cfg(unix)]
+ #[test]
+ fn a_directory_swapped_for_a_symlink_mid_download_cannot_redirect_the_file() {
+ let outside = tempfile::tempdir().unwrap();
+ let dir = tempfile::tempdir().unwrap();
+ let root = OutputRoot::open(dir.path()).unwrap();
+ let target = root.target("late/file.bin").unwrap();
+ let mut staged = target.stage().unwrap();
+ staged.file().unwrap().write_all(b"payload").unwrap();
+
+ fs::rename(dir.path().join("late"), dir.path().join("moved")).unwrap();
+ symlink(outside.path(), dir.path().join("late")).unwrap();
+ staged.commit(false).unwrap();
+
+ assert_eq!(
+ fs::read(dir.path().join("moved/file.bin")).unwrap(),
+ b"payload"
+ );
+ assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
+ }
+}
diff --git a/ant-core/src/data/manifest/output/unix.rs b/ant-core/src/data/manifest/output/unix.rs
new file mode 100644
index 00000000..f7c6c835
--- /dev/null
+++ b/ant-core/src/data/manifest/output/unix.rs
@@ -0,0 +1,162 @@
+//! Unix containment: every operation is relative to a directory file
+//! descriptor, and nothing beneath the root is opened through a link.
+
+use std::fs::File;
+use std::io;
+use std::os::fd::OwnedFd;
+use std::path::{Path, PathBuf};
+
+use rustix::fs::{
+ linkat, mkdirat, open, openat, renameat, statat, unlinkat, AtFlags, FileType, Mode, OFlags,
+};
+use rustix::io::Errno;
+
+use super::{not_a_directory, not_followed, EntryKind};
+use crate::data::manifest::ManifestError;
+
+#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
+use rustix::fs::{renameat_with, RenameFlags};
+
+/// A descriptor pins its directory however paths to it change, so only the
+/// parent needs holding.
+pub(super) const HOLD_ANCESTORS: bool = false;
+
+/// Errors from a no-replace rename on a filesystem that cannot do one.
+#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
+const NO_ATOMIC_NOREPLACE: &[Errno] =
+ &[Errno::INVAL, Errno::NOSYS, Errno::NOTSUP, Errno::OPNOTSUPP];
+/// Errors from a flush on a filesystem that cannot do one.
+const NO_FLUSH: &[Errno] = &[Errno::NOTSUP, Errno::OPNOTSUPP];
+
+/// A directory held open.
+pub(super) struct Dir {
+ fd: OwnedFd,
+ /// Where it was found, for messages.
+ display: PathBuf,
+}
+
+impl Dir {
+ /// Open the output root, following a link if the root is one.
+ pub(super) fn open_root(path: &Path) -> io::Result {
+ let fd = open(
+ path,
+ OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
+ Mode::empty(),
+ )?;
+ Ok(Self {
+ fd,
+ display: path.to_path_buf(),
+ })
+ }
+
+ /// Open the directory `name` in this one, creating it when missing,
+ /// without following a link.
+ pub(super) fn open_child(&self, name: &str) -> Result {
+ let display = self.display.join(name);
+ match mkdirat(&self.fd, name, new_directory_mode()) {
+ Ok(()) | Err(Errno::EXIST) => {}
+ Err(error) => return Err(io::Error::from(error).into()),
+ }
+ let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
+ match openat(&self.fd, name, flags, Mode::empty()) {
+ Ok(fd) => Ok(Self { fd, display }),
+ Err(error) => Err(match self.entry(name) {
+ Ok(Some(EntryKind::Symlink)) => not_followed(&display),
+ Ok(Some(EntryKind::File | EntryKind::Other)) => not_a_directory(&display),
+ // A directory that could not be opened, as one without read
+ // permission: the error says why.
+ _ => io::Error::from(error).into(),
+ }),
+ }
+ }
+
+ /// What `name` is in this directory, without following a link, or
+ /// `None` when nothing has that name.
+ pub(super) fn entry(&self, name: &str) -> io::Result> {
+ let stat = match statat(&self.fd, name, AtFlags::SYMLINK_NOFOLLOW) {
+ Ok(stat) => stat,
+ Err(Errno::NOENT) => return Ok(None),
+ Err(error) => return Err(error.into()),
+ };
+ Ok(Some(match FileType::from_raw_mode(stat.st_mode) {
+ FileType::RegularFile => EntryKind::File,
+ FileType::Directory => EntryKind::Directory,
+ FileType::Symlink => EntryKind::Symlink,
+ _ => EntryKind::Other,
+ }))
+ }
+
+ /// Create the file `name`, failing if anything has that name, even a
+ /// dangling link.
+ pub(super) fn create_new(&self, name: &str) -> io::Result {
+ let flags =
+ OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
+ Ok(File::from(openat(&self.fd, name, flags, new_file_mode())?))
+ }
+
+ /// Open the file `name` for reading, failing if it is a link. The open
+ /// does not wait, so a FIFO swapped in at the name cannot block it.
+ pub(super) fn open_file(&self, name: &str) -> io::Result {
+ let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
+ Ok(File::from(openat(&self.fd, name, flags, Mode::empty())?))
+ }
+
+ /// Rename `from` to `to` in this directory, failing if `to` exists.
+ pub(super) fn rename_noreplace(&self, from: &str, to: &str) -> io::Result<()> {
+ #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
+ match renameat_with(&self.fd, from, &self.fd, to, RenameFlags::NOREPLACE) {
+ Ok(()) => return Ok(()),
+ Err(error) if NO_ATOMIC_NOREPLACE.contains(&error) => {}
+ Err(error) => return Err(error.into()),
+ }
+ // A hard link fails when the target exists. Once it succeeds the file
+ // is in place, so failing to drop the temporary name only leaves a
+ // second name for it.
+ match linkat(&self.fd, from, &self.fd, to, AtFlags::empty()) {
+ Ok(()) => {
+ let _ = unlinkat(&self.fd, from, AtFlags::empty());
+ return Ok(());
+ }
+ Err(Errno::EXIST) => return Err(Errno::EXIST.into()),
+ // No hard links either, as on FAT.
+ Err(_) => {}
+ }
+ // Only a writer outside this extraction can create `to` between this
+ // check and the rename.
+ if self.entry(to)?.is_some() {
+ return Err(io::ErrorKind::AlreadyExists.into());
+ }
+ renameat(&self.fd, from, &self.fd, to).map_err(Into::into)
+ }
+
+ /// Rename `from` over `to` in this directory. A link at `to` is
+ /// replaced, never followed.
+ pub(super) fn rename_replace(&self, from: &str, to: &str) -> io::Result<()> {
+ renameat(&self.fd, from, &self.fd, to).map_err(Into::into)
+ }
+
+ /// Remove the file `name` from this directory.
+ pub(super) fn remove_file(&self, name: &str) -> io::Result<()> {
+ unlinkat(&self.fd, name, AtFlags::empty()).map_err(Into::into)
+ }
+
+ /// Where the directory was found, for messages.
+ pub(super) fn display(&self) -> &Path {
+ &self.display
+ }
+}
+
+/// Whether a flush failed only because the filesystem cannot flush.
+pub(super) fn flush_unsupported(error: &io::Error) -> bool {
+ Errno::from_io_error(error).is_some_and(|errno| NO_FLUSH.contains(&errno))
+}
+
+/// `rwx` for everyone, narrowed by the umask, as `std::fs::create_dir` does.
+fn new_directory_mode() -> Mode {
+ Mode::RWXU | Mode::RWXG | Mode::RWXO
+}
+
+/// `rw` for everyone, narrowed by the umask, as `std::fs::File::create` does.
+fn new_file_mode() -> Mode {
+ Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::WGRP | Mode::ROTH | Mode::WOTH
+}
diff --git a/ant-core/src/data/manifest/output/windows.rs b/ant-core/src/data/manifest/output/windows.rs
new file mode 100644
index 00000000..aad2ad1f
--- /dev/null
+++ b/ant-core/src/data/manifest/output/windows.rs
@@ -0,0 +1,175 @@
+//! Windows containment: each directory from the output root down is held
+//! open without delete sharing, so none of them can be renamed or deleted
+//! while an entry is written. Directories beneath the root are opened as the
+//! reparse point itself, so a junction or symlink is seen rather than
+//! followed. Operations go by path, so a concurrent writer in the output
+//! root that turns an empty directory into a junction is not contained.
+
+use std::fs::{self, File, OpenOptions};
+use std::io;
+use std::os::windows::fs::OpenOptionsExt;
+use std::path::{Path, PathBuf};
+
+use tempfile::TempPath;
+use windows_sys::Win32::Foundation::{
+ ERROR_CALL_NOT_IMPLEMENTED, ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED, WIN32_ERROR,
+};
+use windows_sys::Win32::Storage::FileSystem::{
+ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
+};
+
+use super::{not_a_directory, not_followed, EntryKind};
+use crate::data::manifest::ManifestError;
+
+/// Only open handles keep a directory from being renamed, so every
+/// directory on the chain is held.
+pub(super) const HOLD_ANCESTORS: bool = true;
+
+/// Sharing for a held directory: others may read and write in it, but not
+/// open it for deletion, which renaming it needs as well.
+const HOLD_SHARE_MODE: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE;
+/// Errors from a flush on a volume that cannot do one: drivers without a
+/// flush handler report the first two.
+const NO_FLUSH: &[WIN32_ERROR] = &[
+ ERROR_INVALID_FUNCTION,
+ ERROR_NOT_SUPPORTED,
+ ERROR_CALL_NOT_IMPLEMENTED,
+];
+
+/// A directory held open.
+pub(super) struct Dir {
+ /// Keeps the directory from being renamed, deleted or replaced.
+ _held: File,
+ /// The directory's full path, resolved when the root was opened.
+ path: PathBuf,
+ /// Where it was found, for messages.
+ display: PathBuf,
+}
+
+impl Dir {
+ /// Open the output root. A link in the root's own path is resolved once;
+ /// holding the result keeps that resolution valid. A volume that cannot
+ /// report final paths, such as a RAM disk, is used by its absolute path.
+ pub(super) fn open_root(path: &Path) -> io::Result {
+ let resolved = fs::canonicalize(path).or_else(|_| std::path::absolute(path))?;
+ let held = hold(&resolved, FILE_FLAG_BACKUP_SEMANTICS)?;
+ if !held.metadata()?.is_dir() {
+ return Err(io::ErrorKind::NotADirectory.into());
+ }
+ Ok(Self {
+ _held: held,
+ path: resolved,
+ display: path.to_path_buf(),
+ })
+ }
+
+ /// Open the directory `name` in this one, creating it when missing,
+ /// without following a link.
+ pub(super) fn open_child(&self, name: &str) -> Result {
+ let path = self.path.join(name);
+ let display = self.display.join(name);
+ match fs::create_dir(&path) {
+ Ok(()) => {}
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
+ Err(error) => return Err(error.into()),
+ }
+ let held = hold(
+ &path,
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
+ )?;
+ let metadata = held.metadata()?;
+ if metadata.file_type().is_symlink() {
+ return Err(not_followed(&display));
+ }
+ if !metadata.is_dir() {
+ return Err(not_a_directory(&display));
+ }
+ Ok(Self {
+ _held: held,
+ path,
+ display,
+ })
+ }
+
+ /// What `name` is in this directory, without following a link, or
+ /// `None` when nothing has that name.
+ pub(super) fn entry(&self, name: &str) -> io::Result> {
+ let path = self.path.join(name);
+ let metadata = match fs::symlink_metadata(&path) {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(error),
+ };
+ let file_type = metadata.file_type();
+ Ok(Some(if file_type.is_symlink() {
+ EntryKind::Symlink
+ } else if file_type.is_dir() {
+ EntryKind::Directory
+ } else if file_type.is_file() {
+ EntryKind::File
+ } else {
+ EntryKind::Other
+ }))
+ }
+
+ /// Create the file `name`, failing if anything has that name.
+ pub(super) fn create_new(&self, name: &str) -> io::Result {
+ OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
+ .open(self.path.join(name))
+ }
+
+ /// Open the file `name` for reading, as the link itself if it is one.
+ pub(super) fn open_file(&self, name: &str) -> io::Result {
+ OpenOptions::new()
+ .read(true)
+ .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
+ .open(self.path.join(name))
+ }
+
+ /// Rename `from` to `to` in this directory, failing if `to` exists.
+ pub(super) fn rename_noreplace(&self, from: &str, to: &str) -> io::Result<()> {
+ TempPath::try_from_path(self.path.join(from))?
+ .persist_noclobber(self.path.join(to))
+ .map_err(|error| {
+ // The staged file removes its own temporary name.
+ let _ = error.path.keep();
+ error.error
+ })
+ }
+
+ /// Rename `from` over `to` in this directory. A link at `to` is
+ /// replaced, never followed.
+ pub(super) fn rename_replace(&self, from: &str, to: &str) -> io::Result<()> {
+ fs::rename(self.path.join(from), self.path.join(to))
+ }
+
+ /// Remove the file `name` from this directory.
+ pub(super) fn remove_file(&self, name: &str) -> io::Result<()> {
+ fs::remove_file(self.path.join(name))
+ }
+
+ /// Where the directory was found, for messages.
+ pub(super) fn display(&self) -> &Path {
+ &self.display
+ }
+}
+
+/// Open the directory at `path` and hold it against renaming and deletion.
+fn hold(path: &Path, flags: u32) -> io::Result {
+ OpenOptions::new()
+ .read(true)
+ .share_mode(HOLD_SHARE_MODE)
+ .custom_flags(flags)
+ .open(path)
+}
+
+/// Whether a flush failed only because the volume cannot flush.
+pub(super) fn flush_unsupported(error: &io::Error) -> bool {
+ error
+ .raw_os_error()
+ .and_then(|code| WIN32_ERROR::try_from(code).ok())
+ .is_some_and(|code| NO_FLUSH.contains(&code))
+}
diff --git a/ant-core/src/data/manifest/path.rs b/ant-core/src/data/manifest/path.rs
index a445b5ea..59eea3ec 100644
--- a/ant-core/src/data/manifest/path.rs
+++ b/ant-core/src/data/manifest/path.rs
@@ -7,6 +7,7 @@
use std::collections::HashSet;
use thiserror::Error;
+use unicase::UniCase;
use unicode_normalization::UnicodeNormalization;
/// The only separator a manifest path may use.
@@ -52,15 +53,26 @@ const RESERVED_DEVICE_NAMES: &[&str] = &[
"LPT\u{b2}",
"LPT\u{b3}",
];
-/// Lowest non-control character.
-const FIRST_PRINTABLE: char = '\u{20}';
-/// The DEL control character.
-const DELETE: char = '\u{7F}';
+/// Bidirectional embedding, override and isolate controls. They make a name
+/// display differently from what it is, so `invoice\u{202E}fdp.exe` shows as
+/// `invoiceexe.pdf`.
+const BIDI_CONTROLS: &[char] = &[
+ '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}',
+ '\u{2069}',
+];
+/// Code points HFS+ ignores when it compares names, so a name containing
+/// them is the same file as one without.
+const HFS_IGNORABLE_CHARS: &[char] = &[
+ '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
+ '\u{202E}', '\u{206A}', '\u{206B}', '\u{206C}', '\u{206D}', '\u{206E}', '\u{206F}', '\u{FEFF}',
+];
/// Replacement for a forbidden character when reducing a name to a
/// portable component.
const COMPONENT_REPLACEMENT: char = '_';
/// Prefix that turns a reserved device name into an ordinary one.
const RESERVED_NAME_PREFIX: char = '_';
+/// First character of a hidden name.
+const HIDDEN_NAME_PREFIX: char = '.';
/// Which portable rule a path broke.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
@@ -127,6 +139,15 @@ pub fn validate_path(path: &str) -> Result<(), PathError> {
Ok(())
}
+/// Whether `component` names a hidden file or directory on some
+/// filesystem: it starts with a dot, ignoring the code points HFS+ ignores,
+/// since `\u{200C}.ssh` there is `.ssh`.
+pub fn is_hidden(component: &str) -> bool {
+ component
+ .trim_start_matches(HFS_IGNORABLE_CHARS)
+ .starts_with(HIDDEN_NAME_PREFIX)
+}
+
/// Validate one path component, also used for the manifest `name`.
pub fn validate_component(component: &str) -> Result<(), PathError> {
if component.is_empty() {
@@ -138,10 +159,7 @@ pub fn validate_component(component: &str) -> Result<(), PathError> {
if component == "." || component == ".." {
return Err(PathError::DotComponent(component.to_string()));
}
- if let Some(ch) = component
- .chars()
- .find(|c| *c < FIRST_PRINTABLE || *c == DELETE || FORBIDDEN_CHARS.contains(c))
- {
+ if let Some(ch) = component.chars().find(|c| is_forbidden_char(*c)) {
return Err(PathError::ForbiddenChar {
component: component.to_string(),
ch,
@@ -174,7 +192,7 @@ pub fn portable_component(name: &str) -> Option {
let mut replaced: String = name
.chars()
.map(|c| {
- if c < FIRST_PRINTABLE || c == DELETE || FORBIDDEN_CHARS.contains(&c) {
+ if is_forbidden_char(c) {
COMPONENT_REPLACEMENT
} else {
c
@@ -195,21 +213,32 @@ pub fn portable_component(name: &str) -> Option {
validate_component(&candidate).ok().map(|()| candidate)
}
-/// The key two paths are compared under: NFC normalised, case folded by
-/// uppercasing then lowercasing, and normalised again.
+/// Whether a component may never contain `c`: any Unicode control
+/// character (C0, DEL and C1, which some terminals act on), a bidirectional
+/// control, or a character some platform reserves.
+fn is_forbidden_char(c: char) -> bool {
+ c.is_control() || FORBIDDEN_CHARS.contains(&c) || BIDI_CONTROLS.contains(&c)
+}
+
+/// The key two paths are compared under: without the code points HFS+
+/// ignores, NFC normalised, uppercased, fully case folded, and normalised
+/// again.
///
-/// Lowercasing alone leaves pairs a case-insensitive filesystem merges,
-/// because their uppercase forms are equal: `ſ` and `s`, `ς` and `σ`, `ı`
-/// and `i`. Mapping through uppercase first joins them; the final NFC pass
-/// recomposes anything case mapping decomposed. The result rejects at
-/// least everything APFS and NTFS would merge, and possibly a little more.
+/// Each step covers merges the others miss. NTFS compares uppercase forms,
+/// which joins pairs such as `ſ` and `s` or `ς` and `σ`. APFS applies full
+/// Unicode case folding, which joins `ß`, `ẞ` and `ss`; lowercasing the
+/// uppercase form would leave `ẞ` as `ß`. Dropping the ignorable code points
+/// first lets characters on either side of one compose, and the final NFC
+/// pass recomposes anything case mapping decomposed. The result rejects at
+/// least everything APFS, HFS+ and NTFS would merge, and possibly a little
+/// more, such as `ı` and `i`.
pub fn fold(path: &str) -> String {
- path.nfc()
- .collect::()
- .to_uppercase()
- .to_lowercase()
- .nfc()
- .collect()
+ let significant: String = path
+ .chars()
+ .filter(|c| !HFS_IGNORABLE_CHARS.contains(c))
+ .collect();
+ let upper = significant.nfc().collect::().to_uppercase();
+ UniCase::new(upper).to_folded_case().nfc().collect()
}
/// Reject duplicate effective names and file-versus-directory conflicts
@@ -248,6 +277,16 @@ pub fn check_collisions<'a>(names: impl IntoIterator- ) -> Result<
mod tests {
use super::*;
+ #[test]
+ fn hidden_names_are_found_behind_ignorable_code_points() {
+ for name in [".ssh", "\u{200C}.ssh", "\u{FEFF}\u{200D}.config"] {
+ assert!(is_hidden(name), "{name:?}");
+ }
+ for name in ["ssh", "a.ssh", "\u{200C}ssh"] {
+ assert!(!is_hidden(name), "{name:?}");
+ }
+ }
+
#[test]
fn accepts_ordinary_paths() {
for p in [
@@ -393,6 +432,9 @@ mod tests {
("\u{3c2}", "\u{3c3}"),
("\u{131}", "i"),
("stra\u{df}e", "STRASSE"),
+ // APFS folds capital sharp s to `ss` as well.
+ ("stra\u{1e9e}e", "strasse"),
+ ("\u{df}", "\u{1e9e}"),
] {
assert_eq!(
check_collisions([a, b]),
@@ -419,4 +461,58 @@ mod tests {
));
assert_eq!(check_collisions(["a", "a-b", "ab/c"]), Ok(()));
}
+
+ #[test]
+ fn components_reject_c1_and_bidi_controls_but_keep_joiners_and_marks() {
+ for (name, ch) in [
+ ("csi\u{9B}31m", '\u{9B}'),
+ ("next\u{85}line", '\u{85}'),
+ ("invoice\u{202E}fdp.exe", '\u{202E}'),
+ ("isolated\u{2066}name", '\u{2066}'),
+ ] {
+ assert!(
+ matches!(
+ validate_component(name),
+ Err(PathError::ForbiddenChar { ch: found, .. }) if found == ch
+ ),
+ "{name:?}"
+ );
+ }
+ assert_eq!(
+ portable_component("invoice\u{202E}fdp.exe").as_deref(),
+ Some("invoice_fdp.exe")
+ );
+ // Persian writes ZWNJ inside words, emoji sequences join with ZWJ,
+ // and right-to-left names may carry directional marks.
+ for name in [
+ "می\u{200C}خواهم",
+ "family-\u{1F468}\u{200D}\u{1F469}",
+ "\u{5E9}\u{200F}.txt",
+ ] {
+ assert_eq!(validate_component(name), Ok(()), "{name:?}");
+ }
+ }
+
+ #[test]
+ fn collisions_ignore_the_code_points_hfs_plus_ignores() {
+ for (a, b) in [
+ ("ab", "a\u{200C}b"),
+ ("ab", "\u{FEFF}ab"),
+ ("ab", "a\u{200E}b"),
+ // Composition happens across the ignored code point.
+ ("caf\u{e9}", "cafe\u{200C}\u{301}"),
+ ] {
+ assert!(
+ matches!(check_collisions([a, b]), Err(PathError::Duplicate(_))),
+ "{a:?} / {b:?}"
+ );
+ assert!(
+ matches!(
+ check_collisions([a, &format!("{b}/child")]),
+ Err(PathError::PrefixConflict { .. })
+ ),
+ "{a:?} / {b:?}"
+ );
+ }
+ }
}
diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs
index ac460851..3910bf5a 100644
--- a/ant-core/src/data/mod.rs
+++ b/ant-core/src/data/mod.rs
@@ -74,7 +74,7 @@ pub use manifest::{
file::{manifest_filename_for, read_manifest_file, write_manifest_file},
history::{
default_history_dir, format_timestamp, list_uploads, load_upload, record_upload,
- UploadListing, UploadRecord, UPLOAD_HISTORY_DIR,
+ RecordState, UploadListing, UploadRecord, UPLOAD_HISTORY_DIR,
},
};
pub use manifest::{
diff --git a/ant-core/tests/e2e_manifest.rs b/ant-core/tests/e2e_manifest.rs
index f7d8a3af..ba5bdba4 100644
--- a/ant-core/tests/e2e_manifest.rs
+++ b/ant-core/tests/e2e_manifest.rs
@@ -13,10 +13,10 @@ use std::sync::Arc;
use ant_core::data::{
apply_compaction, data_map_address, embeddable_data_map, embedded_len, extract_manifest,
- manifest_link, parse_link, plan_compaction, publish_data_maps, read_manifest_file,
- record_upload, write_manifest_file, BuildEvent, BuildOptions, Client, ContentRef, EntryStatus,
- ExtractOptions, Link, Manifest, ManifestBuilder, ManifestEntry, ManifestError, PaymentMode,
- ReferenceMode, Visibility, MAX_EMBEDDED_ROOT_MAP_BYTES,
+ list_uploads, load_upload, manifest_link, parse_link, plan_compaction, publish_data_maps,
+ read_manifest_file, write_manifest_file, BuildEvent, BuildOptions, Client, ContentRef,
+ EntryStatus, ExtractOptions, Link, Manifest, ManifestBuilder, ManifestEntry, ManifestError,
+ PaymentMode, RecordState, ReferenceMode, Visibility, MAX_EMBEDDED_ROOT_MAP_BYTES,
};
use self_encryption::{shrink_data_map, ChunkInfo, DataMap};
use serial_test::serial;
@@ -118,6 +118,7 @@ async fn manifest_round_trips_through_file_and_link() {
payment_mode: PaymentMode::Auto,
follow_symlinks: false,
cancel: CancellationToken::new(),
+ history: None,
},
);
builder.add_directory(source.path(), None).unwrap();
@@ -196,8 +197,11 @@ async fn manifest_round_trips_through_file_and_link() {
assert!(!partial.path().join("a.bin").exists());
assert!(partial.path().join("docs/b.bin").exists());
- // Existing targets fail per entry without overwrite and nothing else is
- // touched; with overwrite they are replaced.
+ // Running it again keeps every file that still has its recorded
+ // SHA-256, without downloading it, so a download that stopped can be
+ // continued. The entry added by address records no hash, so its file
+ // fails as existing, and so does a file that differs from its entry;
+ // with overwrite both are replaced.
let report = extract_manifest(
&client,
&from_file,
@@ -206,17 +210,35 @@ async fn manifest_round_trips_through_file_and_link() {
)
.await
.expect("second extract runs");
- assert_eq!(report.failed(), 4);
- assert!(report
- .entries
- .iter()
- .all(|e| matches!(&e.status, EntryStatus::Failed { error } if error.contains("exists"))));
- let mut overwrite = extract_options(out.path(), vec!["a.bin".into()]);
+ assert_eq!(report.present(), 3, "{:?}", report.entries);
+ assert_eq!(report.failed(), 1, "{:?}", report.entries);
+ assert!(report.entries.iter().any(|e| e.name == "extra/extra.bin"
+ && matches!(&e.status, EntryStatus::Failed { error } if error.contains("exists"))));
+ fs::write(out.path().join("a.bin"), b"changed locally").unwrap();
+ let both = vec!["a.bin".into(), "extra/extra.bin".into()];
+ let report = extract_manifest(
+ &client,
+ &from_file,
+ &extract_options(out.path(), both.clone()),
+ None,
+ )
+ .await
+ .expect("third extract runs");
+ assert_eq!(report.failed(), 2, "{:?}", report.entries);
+ assert_eq!(
+ fs::read(out.path().join("a.bin")).unwrap(),
+ b"changed locally"
+ );
+ let mut overwrite = extract_options(out.path(), both);
overwrite.overwrite = true;
let report = extract_manifest(&client, &from_file, &overwrite, None)
.await
.expect("overwrite extract");
- assert_eq!(report.written(), 1);
+ assert_eq!(report.written(), 2, "{:?}", report.entries);
+ assert_eq!(
+ fs::read(out.path().join("a.bin")).unwrap(),
+ vec![0x11u8; FILE_BYTES]
+ );
drop(client);
testnet.teardown().await;
@@ -239,6 +261,7 @@ async fn uploads_record_sha256_and_extraction_checks_it() {
payment_mode: PaymentMode::Auto,
follow_symlinks: false,
cancel: CancellationToken::new(),
+ history: None,
},
);
builder.add_directory(source.path(), None).unwrap();
@@ -308,7 +331,14 @@ async fn failed_build_keeps_completed_uploads_for_history_and_extraction() {
fs::write(&first, &expected).unwrap();
fs::write(&later, vec![0x93; FILE_BYTES]).unwrap();
- let mut builder = ManifestBuilder::new(&client, BuildOptions::default());
+ let history = TempDir::new().unwrap();
+ let mut builder = ManifestBuilder::new(
+ &client,
+ BuildOptions {
+ history: Some(history.path().to_path_buf()),
+ ..Default::default()
+ },
+ );
builder.add_file(&first, "first.bin".into()).unwrap();
builder.add_file(&later, "later.bin".into()).unwrap();
builder
@@ -317,9 +347,9 @@ async fn failed_build_keeps_completed_uploads_for_history_and_extraction() {
let (tx, mut rx) = mpsc::channel(1);
let sabotage = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
- if matches!(event, BuildEvent::FileStarted { index: 0, .. }) {
+ if let BuildEvent::FileStarted { index: 0, .. } = event {
// Preflight has passed. The first file still needs real
- // network/payment work before the next file is read.
+ // network/payment work before the next is read.
if remove {
fs::remove_file(&later).unwrap();
} else {
@@ -359,9 +389,17 @@ async fn failed_build_keeps_completed_uploads_for_history_and_extraction() {
.iter()
.any(|entry| entry.path.as_deref() == Some("later.bin")));
- let history = TempDir::new().unwrap();
- let record = record_upload(history.path(), &partial.manifest, None).unwrap();
+ // The builder recorded the partial result itself.
+ let id = partial
+ .history_id
+ .clone()
+ .expect("the partial build is recorded");
+ assert!(partial.history_error.is_none());
+ let record = load_upload(history.path(), &id).unwrap().unwrap();
+ assert_eq!(record.state, RecordState::Complete);
+ assert_eq!(list_uploads(history.path()).unwrap().records.len(), 1);
let recovered = read_manifest_file(&record.path).unwrap();
+ assert_eq!(recovered, partial.manifest);
let out = TempDir::new().unwrap();
let report = extract_manifest(
&client,
@@ -375,14 +413,77 @@ async fn failed_build_keeps_completed_uploads_for_history_and_extraction() {
assert_eq!(fs::read(out.path().join("first.bin")).unwrap(), expected);
}
+ // A crash once the second upload has started: the build is dropped
+ // there, as a killed process would stop. The first upload was journaled
+ // before the second began, so it is listed as unfinished and usable.
+ let first = source.path().join("first.bin");
+ let second = source.path().join("second.bin");
+ let expected = vec![0x94; FILE_BYTES];
+ fs::write(&first, &expected).unwrap();
+ fs::write(&second, vec![0x95; FILE_BYTES]).unwrap();
+ let history = TempDir::new().unwrap();
+ let mut builder = ManifestBuilder::new(
+ &client,
+ BuildOptions {
+ history: Some(history.path().to_path_buf()),
+ ..Default::default()
+ },
+ );
+ builder.add_file(&first, "first.bin".into()).unwrap();
+ builder.add_file(&second, "second.bin".into()).unwrap();
+ builder
+ .add_public([9; 32], Some("existing.bin".into()), None)
+ .unwrap();
+ let (tx, mut rx) = mpsc::channel(1);
+ // Polled only here, so the build cannot go on once the event is seen.
+ let mut build = Box::pin(builder.finish(Some(tx)));
+ loop {
+ tokio::select! {
+ _ = &mut build => panic!("the build ended before the second upload started"),
+ event = rx.recv() => match event {
+ Some(BuildEvent::FileStarted { index: 1, .. }) => break,
+ Some(_) => {}
+ None => panic!("the build stopped sending progress"),
+ },
+ }
+ }
+ drop(build);
+ drop(rx);
+ let listing = list_uploads(history.path()).unwrap();
+ assert_eq!(listing.records.len(), 1, "{:?}", listing.unreadable);
+ let crashed = &listing.records[0];
+ assert_eq!(crashed.state, RecordState::Unfinished);
+ let mut names: Vec<_> = crashed
+ .manifest
+ .entries
+ .iter()
+ .filter_map(|entry| entry.path.clone())
+ .collect();
+ names.sort();
+ assert_eq!(names, ["existing.bin", "first.bin"]);
+ let record = load_upload(history.path(), &crashed.id).unwrap().unwrap();
+ let out = TempDir::new().unwrap();
+ let report = extract_manifest(
+ &client,
+ &record.manifest,
+ &extract_options(out.path(), vec!["first.bin".into()]),
+ None,
+ )
+ .await
+ .unwrap();
+ assert_eq!(report.written(), 1);
+ assert_eq!(fs::read(out.path().join("first.bin")).unwrap(), expected);
+
// References do not count toward the original upload total, including
// when cancellation happens before the first upload starts.
let cancel = CancellationToken::new();
cancel.cancel();
+ let history = TempDir::new().unwrap();
let mut builder = ManifestBuilder::new(
&client,
BuildOptions {
cancel,
+ history: Some(history.path().to_path_buf()),
..Default::default()
},
);
@@ -396,6 +497,9 @@ async fn failed_build_keeps_completed_uploads_for_history_and_extraction() {
assert!(cancelled.cancelled);
assert_eq!(cancelled.files_uploaded, 0);
assert_eq!(cancelled.total_to_upload, 2);
+ // Nothing was paid for, so nothing is recorded.
+ assert!(cancelled.history_id.is_none());
+ assert!(fs::read_dir(history.path()).unwrap().next().is_none());
assert_eq!(cancelled.manifest.entries.len(), 1);
drop(client);
@@ -428,6 +532,7 @@ async fn compact_mode_uses_public_addresses_only_when_the_datamap_is_on_the_netw
payment_mode: PaymentMode::Auto,
follow_symlinks: false,
cancel: CancellationToken::new(),
+ history: None,
},
);
builder.add_directory(source.path(), None).unwrap();
@@ -457,6 +562,7 @@ async fn compact_mode_uses_public_addresses_only_when_the_datamap_is_on_the_netw
payment_mode: PaymentMode::Auto,
follow_symlinks: false,
cancel: CancellationToken::new(),
+ history: None,
},
);
embedded_builder.add_directory(source.path(), None).unwrap();
diff --git a/docs/adr/ADR-0006-file-manifests-and-links.md b/docs/adr/ADR-0006-file-manifests-and-links.md
index de984158..e8402b65 100644
--- a/docs/adr/ADR-0006-file-manifests-and-links.md
+++ b/docs/adr/ADR-0006-file-manifests-and-links.md
@@ -2,7 +2,7 @@
- **Status:** Proposed
- **Date:** 2026-09-23
-- **Last amended:** 2026-10-15
+- **Last amended:** 2026-10-26
- **Decision owners:** Mick
- **Reviewers:**
- **Supersedes:** none
@@ -283,8 +283,14 @@ A path is valid when all of the following hold:
- Valid UTF-8, with `/` as the only separator. `\` is a forbidden character,
never a separator.
- No leading `/`, no empty component, no component equal to `.` or `..`.
-- No component contains NUL, a control character (below `0x20`, or `0x7F`),
- or any of `< > : " | ? * \`.
+- No component contains a Unicode control character (`U+0000`–`U+001F`,
+ `U+007F`–`U+009F`; some terminals act on the C1 range, and entry names are
+ printed), a bidirectional embedding, override or isolate control
+ (`U+202A`–`U+202E`, `U+2066`–`U+2069`), or any of `< > : " | ? * \`.
+ Bidirectional controls make a name display as something it is not:
+ `invoicefdp.exe` shows as `invoiceexe.pdf`. Joiners and directional
+ marks stay allowed because real names in Persian, emoji and right-to-left
+ scripts use them.
- No component ends with a space or a `.`.
- No component, with any extension and the spaces before it removed,
equals a Windows reserved device name ignoring case: `CON`, `PRN`, `AUX`,
@@ -292,13 +298,19 @@ A path is valid when all of the following hold:
and `LPT¹`–`LPT³`. So `CON .txt` is rejected as well as `CON.txt`.
- Each component is at most `MAX_PATH_COMPONENT_BYTES` (255) bytes; the whole
path is at most `MAX_PATH_BYTES` (1024) bytes.
-- Effective names are compared under a folded key: NFC normalisation, then
- uppercasing followed by lowercasing, then NFC again. Lowercasing alone
- misses pairs that APFS and NTFS merge because their uppercase forms are
- equal (`ſ` and `s`, `ς` and `σ`, `ı` and `i`); going through uppercase
- joins them, and the final normalisation recomposes anything case mapping
- decomposed. The key may reject a few pairs a given filesystem would keep
- apart (`ß` and `ss`), which is the safe direction. Under that comparison
+- Effective names are compared under a folded key: the code points HFS+
+ ignores in names are dropped (zero-width joiners, directional marks and
+ formatting controls, and `U+FEFF`), then NFC normalisation, uppercasing,
+ full Unicode case folding, and NFC again. Each step covers merges the
+ others miss. NTFS compares uppercase forms (`ſ` and `s`, `ς` and `σ` on
+ older volumes). APFS applies full case folding (`ß`, `ẞ` and `ss`; `µ` and
+ `μ`), which lowercasing the uppercase form would miss for `ẞ`. HFS+ treats
+ `ab` and `ab` as one name. The final normalisation recomposes
+ anything case mapping decomposed. The key is checked against every BMP
+ character in the Windows XP and Windows 7 NTFS upcase tables and against
+ every cased character on a live case-insensitive APFS volume; it misses
+ no merge either makes. It rejects a few pairs those filesystems keep
+ apart (`ı` and `i`), which is the safe direction. Under that comparison
no two effective names are equal, and none is a directory prefix of
another: `a` and `a/b` cannot both be entries, and neither can `A` and
`a/b`.
@@ -397,11 +409,27 @@ Two reference modes for uploaded files:
Everything that could make the finished manifest invalid, such as file sizes,
the name and path collisions, is checked before the first paid upload, and
-the output location is checked before it too. Cancellation stops between
+the output location is checked before it too.
+
+The finished manifest must also fit `MAX_MANIFEST_BYTES`, which a few
+thousand embedded root maps can exceed. Before the first upload every queued
+file is counted at the largest entry it can end as, with its path, the
+largest size and a hash: its own DataMap of three chunk records at the
+deepest child level, which bounds every map an upload returns. A build that
+cannot fit so is refused. (A file that ends as a public address is smaller,
+so the check can refuse a build that would just have fitted, never the
+reverse.) After an upload the entry is counted at its real size, and a
+larger reference such as an embedded root map is used only when the
+manifest still fits with it; the shrunk map is kept otherwise. A build that
+passes the check therefore never stops for size, every partial or complete
+result can be encoded and recorded, and nothing is paid for that could not
+be. Cancellation stops between
files or abandons the upload in flight and returns the partial manifest,
-which the client records in the upload history so paid uploads are not lost.
-Ordinary file failures also preserve the partial result in
-`ManifestError::BuildFailed`; the CLI records it before reporting failure.
+which the builder records in the upload history so paid uploads are not
+lost. Ordinary file failures also preserve the partial result in
+`ManifestError::BuildFailed`, recorded the same way before it is returned.
+A crash or a forced quit loses no paid upload either: the build journals
+every completed upload as it goes (see Upload history).
Each successful upload's DataMap is retained before reference optimisation,
so a root-map resolution failure cannot discard that upload. Partial results
carry the original number of local files to upload, excluding entries added
@@ -424,18 +452,67 @@ A single-file upload becomes a one-entry manifest; a manifest build is
recorded as itself. Records live under `/uploads/` as ordinary
`.ant` files named `-.ant`, where the filename stem is
the record id. When that id is taken, as when uploads with the same label
-finish within one second, `-2`, `-3` and so on are appended. Nothing else is
-stored, so the history is a plain folder of manifests. The client lists
+finish within one second, `-2`, `-3` and so on are appended. Apart from the
+journals of builds that have not finished, nothing else is stored, so the
+history is a plain folder of manifests. The client lists
records newest first, ordering ids within one second by their suffix as a
number, and accepts a record id wherever it accepts a manifest file or link:
`show`, `link`, `export` and `download`. Every record embeds the full
DataMap, whether or not the upload was public, and records the upload's
`sha256`. A file name that breaks the
portable path rules is repaired for the record, or dropped so the entry
-extracts under its content address, rather than losing the record. Recording never fails an
+extracts under its content address, rather than losing the record. A record
+written under earlier, looser path rules is repaired the same way when it is
+loaded: of two entries whose names now collide the later extracts under
+its content address, and a file whose name is now that of a directory other
+entries are in does, rather than every entry in it. A client update so
+never makes a recorded upload unreachable. Records loaded from anywhere else, such as a shared `.ant` file
+or a link, are still refused when they break the rules. Recording never fails an
upload that has already succeeded; an unusable history directory is reported
and the upload result still shown.
+A manifest build pays for its uploads one at a time, so it also keeps a
+journal in the history while it runs, `.ant.partial`, and a crash, a
+kill, a power cut or a forced quit cannot lose an upload that was paid for:
+
+- The journal is created, and its name made durable, before the first paid
+ upload. A build that cannot create it stops before paying for anything.
+ Entries added without uploading are recorded first.
+- Each completed upload's entry is flushed to disk before the next upload is
+ paid for. When the entry's reference is then finalised, for example by
+ embedding the root map, it is recorded again; the later record of a name
+ wins.
+- When the build ends, complete, cancelled or failed, its manifest becomes
+ the ordinary record under the same id. The history directory is synced
+ before the journal is removed, so a power cut cannot lose both. A build
+ that stops before anything was uploaded paid for nothing and leaves no
+ record. If the record cannot be written, the journal stays; the CLI then
+ writes a stopped build's partial manifest to the output file, since the
+ journal may lack an upload whose entry could not be appended. An existing
+ output file is never replaced by a partial manifest, which goes beside it
+ as `.partial.ant` instead.
+- Holding an id's journal is what claims the id: every record, a
+ single-file upload's included, is written by the holder of its journal,
+ which is created exclusively before the record is looked for. Two writers
+ therefore never take one id, and a record never takes the id of a running
+ build.
+- A journal is listed as an **unfinished** upload holding every entry that
+ reached the disk, whether its build is still running or stopped without
+ finishing, and its id is accepted wherever a record id is, with a reminder
+ that it is partial. A journal is read up to its last whole frame, so one
+ still being written reads as the uploads so far. A journal beside a record
+ of the same id, left by a build that stopped between the steps above, is
+ ignored, and so is one holding no entry yet.
+- On Unix the journal is readable by its owner only, as records are, since
+ it holds the DataMaps of private uploads.
+
+The journal is the magic `0xC1 'A' 'N' 'J'`, a version byte (1), and frames:
+a little-endian `u32` payload length, the payload's CRC-32, and the payload.
+The first payload is the manifest's `name` and `torrent`; every other
+payload is one entry in the manifest's own msgpack form. Reading stops at
+the first frame that is incomplete or fails its checksum, which is where a
+write was cut short; every frame before it is kept.
+
### Export and compaction
Any manifest the client can load, from a file, a link or the upload history,
@@ -463,38 +540,85 @@ manifest untouched. Entries already referenced by address are unaffected.
Input: a manifest, an output root, and an optional selection by exact
effective name or directory prefix. Empty selection means everything.
+The output root is the caller's choice. When the CLI is not given one, it
+extracts into a new directory named after the manifest, made in the user's
+downloads directory before anything is downloaded; making it is the check.
+That is the platform's Downloads folder (the known folder on Windows,
+`XDG_DOWNLOAD_DIR` on Linux), or `~/Downloads` where the platform names
+none, made if missing, and the CLI prints the directory before it starts.
+Like a browser's, this default keeps a sender's name out of the directory
+the command runs in, where a name can mean something: a project directory
+loads a package named `json` before the real one, and a home directory
+holds `.ssh` and `AppData`. It also belongs to the user, so no other user
+can swap the new directory for a link while it fills. The sender still
+chooses that name, and a downloads directory may be the home directory, so
+a default never lands in a directory that exists already or has a hidden
+name, one starting with a dot once the code points HFS+ ignores are
+dropped: a manifest named `.ssh` would otherwise plant `authorized_keys`.
+Such a manifest, and one without a name, needs an explicit output
+directory, which may be any directory, existing or not. The CLI's messages never echo a sender's name
+inside a suggested command. The selection is checked before the directory
+is made. A download that stops can be run again: the CLI
+records the directory it made under `/downloads/`, keyed by a hash
+of the manifest and the directory's path, until every entry is in place, and
+a re-run of the same download continues into it. The sender cannot make that
+record, so it does not let a name pick an existing directory.
+
Before any network access, every effective name is re-validated against the
portable path rules. Any violation rejects the manifest.
Containment is a filesystem contract, not a string check. Within the output
root the extractor never follows a symlink or a junction, that is, anything
the platform's no-follow metadata reports as a link (on Windows, the
-name-surrogate reparse points): each
-intermediate directory is created, or confirmed to be a real directory, by a
-non-following metadata check immediately before use, and the file is
-downloaded into a reserved temporary name in its parent directory and renamed
-into place with no-clobber semantics. The target is checked again, without
-following links, immediately before the rename. An entry whose path would
-traverse a link, or whose target already exists, is a per-entry failure
-unless overwrite was requested, in which case only a regular file at the
-exact target is replaced, never a link. Handle-relative (resolve-beneath)
-opening would close the remaining check-to-use window against a concurrent
-local attacker and is a later hardening step, not part of this decision.
+name-surrogate reparse points). It resolves every name from directories it
+holds open:
+
+- On Unix each directory is held as a file descriptor opened without
+ following links, and every create, rename and check is relative to it.
+ Renaming a directory or replacing any path to it with a symlink does not
+ change the directory a descriptor refers to, so nothing that changes
+ while an entry downloads can redirect where it is written, even a
+ concurrent local process that can write inside the output root.
+- On Windows each directory from the root down is held open without delete
+ sharing, which stops it from being renamed or deleted while the entry is
+ written. Directories beneath the root are opened as the reparse point
+ itself, so a link is seen rather than followed. Operations still go by
+ path, so a concurrent process that can write inside the output root can
+ turn a directory the extraction created, while it is still empty, into a
+ junction. Containing that needs handle-relative opens through the native
+ API and is left to a later change; links that exist when a name is
+ resolved are always refused.
+
+The output root itself may be a link, since it is the caller's choice; links
+in its own path are resolved once when it is opened. Each intermediate
+directory is created, or confirmed to be a real directory, as it is opened.
+The file is downloaded into a temporary name in its parent and renamed into
+place without replacing anything (an atomic no-replace rename where the
+filesystem has one, otherwise a hard link, otherwise a check just before the
+rename). A regular file already at the target with the entry's recorded
+`sha256` is that entry, as an earlier run left it: it is kept and reported
+as present, and nothing is downloaded, so an extraction that stopped can be
+run again. Its length is compared with the entry's size first, so a large
+file that is not the entry is never read, and a file that cannot be read is
+not the entry. Any other entry whose path would traverse a link, or whose
+target already exists, is a per-entry failure unless overwrite was
+requested. Even then only a regular file is replaced, never a link.
Per entry: resolve the DataMap (`Public` needs one fetch), resolve it to its
root (a shrunk map needs its wrapper records), and check that the target's
filesystem has room for the root's exact plaintext size before downloading;
too little space is a per-entry failure. Then download through the ordinary
file download and write as above. When the entry records a `sha256`, the
-downloaded temporary file is hashed before it takes its name; a mismatch is
-a per-entry failure and the temporary file is removed, so nothing unverified
-is left at the target. The recorded `size` decides nothing; tools show it
+download is hashed as it is written, in the same pass, and checked before
+the file takes its name; a mismatch is a per-entry failure and the temporary
+file is removed, so nothing unverified is left at the target. The file is
+flushed to disk before the rename, where the filesystem can flush. The recorded `size` decides nothing; tools show it
marked as the creator's claim, and show the exact size instead wherever an
embedded root map provides one. Extracted files get the platform's default
permissions.
A bounded number of entries are in flight at once, one by default. Results
-are per entry: written, failed with its error, or cancelled. One failure does
+are per entry: written, already present, failed with its error, or cancelled. One failure does
not abort the rest. The operation reports progress at manifest and file level
and accepts a cancellation token; cancelling abandons the in-flight download
and marks every unstarted entry cancelled.
@@ -552,8 +676,10 @@ initial release.
- A link's embedded entries carry the shrunk map, so a recipient of a large
file fetches its wrapper records before its data, which a `.ant` file
with the root map avoids.
-- Existing files in the output directory are never reused; a re-download
- fails per entry or overwrites on request.
+- An existing file in the output directory is reused only when it has its
+ entry's recorded `sha256`. An entry without one, such as an entry added by
+ address, fails per entry when its file exists, or is downloaded again with
+ overwrite.
- A new format is a new compatibility surface; the version byte and the
forward-compatibility rules bound it.
@@ -595,17 +721,52 @@ initial release.
- Portable path tests, run on Unix and macOS in CI: every rule above,
positive and negative, including the extended reserved names and the
case-folding pairs above.
+- Default output tests: without an explicit output, a download makes a new
+ directory named after the manifest in the downloads directory, making
+ that too if it is missing, and records it; the same download continues
+ into it, while another manifest of that name, or this one once its record
+ is gone, is refused; a hidden name, also behind a code point HFS+
+ ignores, or no name is refused, making nothing.
- Containment tests, run on Unix and macOS in CI: a pre-existing symlink at
every depth inside the output root causes a per-entry failure and nothing
is written outside the root; an existing target fails without overwrite
and is replaced only when it is a regular file; a link at the target is
- never followed even with overwrite. Windows uses the same no-follow
- metadata checks, but CI has no Windows unit-test runner. Junction tests
- are to be added with one.
+ never followed even with overwrite; a directory swapped for a symlink to
+ elsewhere while an entry downloads cannot redirect the file; a name that
+ appears during the download is not replaced; an abandoned temporary file
+ is removed; a file with the entry's hash and size is kept, while a link, a
+ FIFO or an unreadable file at the target never counts, and overwrite still
+ replaces an unreadable one. The
+ Windows implementation
+ is type-checked and linted for `x86_64-pc-windows-msvc`, but CI has no
+ Windows test runner. Junction tests are to be added with one.
+- Upload journal tests: a journal lists as an unfinished record of exactly
+ the entries written, while its build runs and after it stops, with a later
+ record of a name replacing an earlier one; a write cut short, or a
+ damaged last frame, keeps every entry before it; finishing writes the
+ record and removes the journal; journals and records never share an id;
+ a journal beside its record is not listed, nor is one cut short before its
+ opening; recording a single upload leaves no journal; a record written
+ under looser rules loads repaired, with nothing lost, and a file in the
+ way of a directory loses its path rather than the entries in it. On a
+ local network, a build that fails after its first upload records its
+ partial result itself; a build dropped once its second upload has started,
+ as a killed process stops, leaves an unfinished record holding the first
+ upload, which extracts; and a build cancelled before any upload leaves no
+ record.
+- Size budget tests: the largest entry an upload can become bounds every
+ real one; a build of long paths that would fit as addresses but not
+ embedded is refused before paying; the fixed part counts everything but
+ the entries; a larger reference is taken only when it fits; a build that
+ passes preflight with less than a file to spare, whose uploads all return
+ shrunk maps and are offered other references, never outgrows the limit.
- End-to-end on a local network: build a manifest with an embedded entry, a
compact entry and an entry added by address; extract it from a `.ant` file
and from a manifest link; extract a selection; verify bytes and per-entry
- results; download a file link through the ordinary file download.
+ results; run it again into the same directory, where every file with its
+ recorded hash is kept without a download, while a file without one, or
+ one that differs from its entry, fails until overwrite replaces it;
+ download a file link through the ordinary file download.
- Review trigger: any new `ContentRef` variant, or any change to the header,
path rules or containment contract, requires an amending or superseding
ADR.