Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ ant-core/src/
│ ├── link.rs # ant://<address> and ant://manifest/<base64url> links
│ ├── file.rs # .ant file read/write
│ ├── history.rs # Upload history: one .ant per upload under <data dir>/uploads
│ ├── journal.rs # Crash-safe journal of a running build (<id>.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.
Expand Down Expand Up @@ -162,7 +164,11 @@ cargo run --bin ant -- --help # Run the CLI
a plain file link. Every upload (`ant file upload`, `ant manifest create`) is also recorded as
a manifest under `<data dir>/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.
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 `<id>.ant.partial` before paying for the next, so a
crash loses no paid upload; leftover journals list as interrupted 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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

179 changes: 141 additions & 38 deletions ant-cli/src/commands/data/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ use ant_core::data::{
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,
ManifestBuilder, ManifestError, PaymentMode, RecordState, ReferenceMode, TorrentReference,
UploadEvent, UploadRecord, Visibility, MANIFEST_LINK_RECOMMENDED_MAX_BYTES,
};
use clap::Subcommand;
use serde_json::json;
Expand Down Expand Up @@ -491,6 +491,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())?;
Expand Down Expand Up @@ -547,8 +550,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, args.overwrite)?;
write_manifest(&result.manifest, &out, args.overwrite)?;
if !json {
eprintln!(
Expand All @@ -571,10 +573,14 @@ 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<BuildResult, ManifestError>,
record: impl FnOnce(&Manifest) -> Option<String>,
out: &Path,
overwrite: bool,
) -> anyhow::Result<(BuildResult, Option<String>)> {
let result = match built {
Ok(result) => result,
Expand All @@ -583,24 +589,59 @@ 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, overwrite),
);
}
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, overwrite),
));
}
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<String> {
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 interrupted"
);
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.
fn keep_partial(partial: &BuildResult, out: &Path, overwrite: bool) -> String {
match recorded_id(partial) {
Some(id) => format!("; the partial manifest is recorded as {id}"),
None if partial.files_uploaded == 0 => String::new(),
None => match write_manifest(&partial.manifest, out, overwrite) {
Ok(()) => format!("; the partial manifest is written to {}", out.display()),
Err(error) => format!("; the partial manifest could not be kept: {error}"),
},
}
}

/// The output path: the one given, or `<name>.ant` in the current directory.
fn resolve_output(name: Option<&str>, output: Option<PathBuf>) -> anyhow::Result<PathBuf> {
if let Some(name) = name {
Expand All @@ -621,21 +662,12 @@ fn resolve_download_root(manifest: &Manifest, output: Option<PathBuf>) -> anyhow
}))
}

fn partial_record_message(history_id: Option<&str>) -> String {
history_id
.map(|id| format!("; the partial manifest is recorded as {id}"))
.unwrap_or_default()
}

fn cancelled_build_error(
result: &ant_core::data::BuildResult,
history_id: Option<&str>,
) -> anyhow::Error {
/// `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),
)
}

Expand Down Expand Up @@ -759,6 +791,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(),
})
})
Expand Down Expand Up @@ -803,8 +836,13 @@ 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::InProgress => " (in progress)",
RecordState::Interrupted => " (interrupted: the files listed were uploaded)",
};
println!(
"{:<23} {:>7} {:>12} {}",
"{:<23} {:>7} {:>12} {}{state}",
stamp,
record.manifest.entries.len(),
size,
Expand Down Expand Up @@ -992,14 +1030,27 @@ fn load_manifest(source: &str) -> anyhow::Result<Manifest> {
}
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`)"
),
}
}

/// A recorded upload's manifest, with a reminder of what an interrupted one
/// holds. One still in progress is never loaded: the history refuses it.
fn record_manifest(record: UploadRecord) -> Manifest {
if record.state == RecordState::Interrupted {
eprintln!(
"warning: upload {} was interrupted; it holds the {} file(s) uploaded before it stopped",
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<Option<TorrentReference>> {
let mut merged: Option<TorrentReference> = None;
Expand Down Expand Up @@ -1215,6 +1266,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 {
Expand All @@ -1225,15 +1278,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, false)
.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,
Expand All @@ -1254,6 +1310,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)
Expand All @@ -1264,23 +1322,68 @@ 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, false).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, false).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 finished build keeps its manifest in the output anyway; only
// the record id is not claimed.
let (_, history_id) = build_outcome(Ok(partial), &out, true).unwrap();
assert!(history_id.is_none());
}

#[test]
fn public_file_spec_parses_with_and_without_path() {
let hex64 = "ab".repeat(32);
Expand Down
9 changes: 8 additions & 1 deletion ant-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,6 +32,8 @@ sha2 = "0.10"
# Portable WebRTC Direct profile shared with ant-node.
saorsa-transport = { version = "0.38.1", 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"
Expand Down Expand Up @@ -125,9 +129,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
Expand Down
Loading
Loading