Repository navigation
fix(manifest): harden extraction, crash safety and portable names (ADR-0006) - #234
Draft
mickvandijke wants to merge 5 commits into
Draft
mickvandijke wants to merge 5 commits into
mickvandijke wants to merge 5 commits into
Conversation
Folding uppercased then lowercased, which misses a merge APFS makes: `ẞ` lowercases to `ß` while `ß` becomes `ss`, so `straẞe` and `strasse` passed as two entries but are one file on a Mac. The key now applies full Unicode case folding after uppercasing. Checked against every BMP character in the Windows XP and Windows 7 NTFS upcase tables and every cased character on a live case-insensitive APFS volume, it misses no merge either makes. It also drops the code points HFS+ ignores in names, so `ab` and `a<U+200C>b` collide as they do on HFS+. Components now reject every Unicode control character, including the C1 range some terminals act on when entry names are printed, and the bidirectional embedding, override and isolate controls that make a name display as something it is not (`invoice<U+202E>fdp.exe`). Joiners and directional marks stay allowed for Persian, emoji and right-to-left names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extraction checked each directory by path, then created the temporary file, re-read it for the SHA-256 and renamed it by path. A process able to write inside the output root could swap a checked directory for a symlink in between and have the file written wherever the link pointed, which the ADR listed as the remaining hardening step. Every name beneath the root is now resolved from directories held open. On Unix each directory is a descriptor opened without following links and every create, check and rename is relative to it, so renaming a directory or replacing a path to it changes nothing. On Windows each directory from the root down is held without delete sharing, so none can be renamed, deleted or replaced by a junction while an entry is written; directories are opened as the reparse point itself. The rename into place is a no-replace rename (renameat2/renameatx_np, then a hard link, then a check just before the rename on filesystems with neither). The SHA-256 is computed as the download is written, through the new Client::file_download_to_writer, and the file is synced before it takes its name. Overwrite no longer replaces a file the same extraction already wrote. Two entries a filesystem treats as one name, such as a long name and its Windows 8.3 alias, now end with the second failing instead of silently replacing the first. Files are told apart by device and inode, or by volume and file id on Windows. The path-based `extract::prepare_target` is removed. The Windows code is type-checked and linted for x86_64-pc-windows-msvc; CI has no Windows test runner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Embedded mode embeds each file's root map when it is at most 64 KiB, and a manifest is capped at 64 MiB, so about a thousand large files could fill it. The cap was only enforced when the finished manifest was encoded, after every upload had been paid for, and then the history record and the output file both failed. The builder now keeps a size budget. Preflight counts every queued file at the smallest entry it can become and refuses a build that cannot fit even so. Each file is uploaded only if the largest entry it can become, its own shrunk DataMap of at most three chunk records, would still fit; otherwise the build stops before paying for it, as a file failure that keeps the partial result. After an upload the entry is counted at its real size, and the embedded root map, or any larger reference, is used only when the manifest still fits with it. Every partial or complete result can therefore be encoded and recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A manifest build held every completed upload in memory until it ended. Cancellation and file failures returned the partial result for the CLI to record, but a crash, a kill, a power cut or the second Ctrl-C lost every DataMap uploaded so far, and for a private upload that DataMap exists nowhere else. With BuildOptions::history set, the builder now creates <id>.ant.partial in the upload history before the first paid upload and flushes each completed upload's entry to it before paying for the next; a finalised reference is recorded again and the later record wins. The journal is length-prefixed, CRC-32-checked frames, so reading stops cleanly where a write was cut short. When the build ends, complete, cancelled or failed, the builder writes the ordinary record under the same id and removes the journal, and returns the id in BuildResult::history_id. A build that uploaded nothing leaves no record, and one that cannot create its journal stops before paying for anything. A journal left behind is listed as an interrupted upload holding every entry that reached the disk, and its id works with show, link, export and download. While its build runs the journal is locked and listed as in progress. Records and journals never share an id. `ant manifest list` marks both states and its JSON gains `state`. The CLI no longer records builds itself: it passes the history directory and reports the builder's record id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…raction - The size budget counted every queued file as a bare public address, an entry the default embedded mode never makes. A build that could never fit passed preflight and stopped only after many paid uploads. Uploads are now counted at their own three-chunk embedded map, except in compact mode with public uploads. - The stricter path rules also ran when loading history records, so a record an older client wrote could stop loading and its paid uploads became unreachable. History records and journals are now repaired on load, as new records are: invalid names are made portable, and the later of two colliding entries extracts under its content address. Manifests from anywhere else are still refused. - A record and a running build could claim the same id, which hid the build's journal. Holding an id's journal now claims the id, and every record, including a single upload's, is written through one. The journal's lock is taken blocking, so a listing at the moment of creation can no longer make the build fail. A journal cut short before its opening, or one that stopped holding nothing, is not listed. - The history directory is synced after the record is written and before the journal is removed, so a power cut cannot lose both. - The CLI refuses an upload id that is still in progress, which on Windows cannot be read and could export or download nothing. It warns that an interrupted record is partial. - Once extraction's rename succeeds, reading the file's identity back can no longer fail the entry, and a temporary name the hard-link fallback leaves behind is removed. The commit's fsync runs in block_in_place. - A late canonicalize failure returns BuildFailed with the partial result instead of a bare error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #219 (ADR-0006). Closes the remaining safety gaps in file manifests across Windows, macOS and Linux. Four commits do the work and a fifth fixes what review found:
811435a). Folding missed a merge APFS makes:ẞlowercases toßwhileßbecomesss, sostraẞeandstrassepassed as two entries but are one file on a Mac. The key now applies full Unicode case folding after uppercasing, and drops the code points HFS+ ignores (ab/a<U+200C>b). It was 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, and misses no merge either makes. Components also reject C1 controls, which some terminals act on when entry names are printed, and bidi override and isolate controls (invoice<U+202E>fdp.exe). Joiners and directional marks stay allowed.75a4815). Extraction used to check directories by path and then create, re-read and rename the file by path. A process able to write inside the output root could swap a checked directory for a symlink in between and get the file written wherever the link pointed. The ADR listed this as the remaining hardening step. Every name is now resolved from directories held open. On Unix they are descriptors and every operation is*at(viarustix). On Windows the chain from the root down is held without delete sharing, so it cannot be renamed, deleted or replaced by a junction. The final step is a no-replace rename. SHA-256 is computed while writing (newClient::file_download_to_writer), and the file is synced before it takes its name. Overwrite also never replaces a file written earlier in the same extraction, so a long name and its Windows 8.3 alias cannot silently become one file.ee8c6c7). About a thousand embedded root maps (64 KiB each) can exceed the 64 MiB manifest limit, which was only checked after every upload was paid for. Now each file is uploaded only if the largest entry it can become still fits. The larger root-map reference is used only when it fits, and the shrunk map is kept otherwise.e31cb8a). A crash, kill, power cut or second Ctrl-C used to lose every DataMap uploaded so far, and for private uploads those DataMaps exist nowhere else. The builder now writes a CRC-checked journal (<id>.ant.partial) to the upload history and flushes each entry before paying for the next upload. The journal becomes the ordinary record when the build ends. A leftover journal lists as an interrupted upload, andshow,link,exportanddownloadall accept its id.4c3c545), from a high-effort code review of this PR:Linear issue
Closes V2-1478
Risk tier
Compatibility
.antbytes are unchanged. Adds a local upload-history file, the build journal<id>.ant.partial(magicC1 41 4E 4A, version 1, frames of little-endianu32length + CRC-32 + named msgpack), specified in ADR-0006. Older clients list only.antfiles and ignore journals. The path rules are stricter: names containing C1 or bidi controls, and entries that collide only under the new key (ẞ/ss, HFS+ ignorables), were accepted before and are now rejected at build, decode and extract time. The upload history is the exception. A record written by an earlier client is repaired when it is loaded, rather than refused: names are made portable, and the later of two colliding entries extracts under its content address. Recorded uploads therefore stay reachable.extract::prepare_target. New public fieldsBuildOptions::history,BuildResult::{history_id, history_error}andUploadRecord::state, plus theRecordStateenum (re-exported). Struct literals must set the new fields. AddedClient::file_download_to_writer. CLI changes:ant manifest createrecords the build itself through the journal, and refuses to start, before paying anything, if the upload history cannot be written. Previously it warned after uploading.ant manifest listmarks in-progress and interrupted uploads, and its JSON gainsstate. Commands that take a record id refuse an upload still in progress, and warn that an interrupted one is partial.ant file uploadrecords claim their id through the same journal mechanism, with an unchanged file layout.download --overwriteno longer replaces a file written earlier in the same extraction.Semver impact
Test evidence
cargo fmt --all -- --check,cargo clippy --all-targets --all-features -- -D warnings, andcargo clippy -p ant-core --target wasm32-unknown-unknown --no-default-features --features browser-wasm -- -D warnings: all clean.cargo test --lib --all: 862 passed. New tests:ß/ẞ/ss.cargo test -p ant-cli --bin ant: 32 passed, including in-progress ids being refused and interrupted ones used.cargo test -p ant-core --features test-utils --test e2e_manifest -- --test-threads=1: 7 passed in 286 s against the in-process testnet and Anvil, all through the new extraction path including SHA-256 checks. The failed-build test now shows the first paid upload in the in-progress journal before the build continues, the partial result recorded by the builder itself, and no record for a build cancelled before any upload.ntfs_upcase_table_build, XP and Win7 variants) and compares every BMP character. It also creates a file for every cased character on this Mac's case-insensitive APFS and records which names APFS merges. Result: 0 misses on all three; the same probe found theẞgap in the previous fold.x86_64-pc-windows-msvcin an isolated crate with the samewindows-sys,tempfileandrandversions. ant-core's C dependencies do not cross-compile here, and CI has no Windows runner, so the Windows path has not been run.python3 scripts/adr-governance.py: passes./code-review highon this PR raised 10 findings, which I checked against the code. Nine were real and are fixed in4c3c545. The tenth, that removingprepare_targetis breaking, is already declared below.New dependency
No new crates in
Cargo.lock. Three crates that were already in the tree become direct ant-core dependencies:unicase2.9, for full Unicode case folding (already present viawebrtc).crc32fast1.5, for journal frame checksums (already present viaflate2).rustix1.1 withfs, Unix only, for directory-relative file operations (already present viatempfile).The existing Windows
windows-sys0.61 dependency gains theWin32_Storage_FileSystemfeature, used for file identity.ADR
docs/adr/ADR-0006-file-manifests-and-links.md (status Proposed, amended by this PR: portable path rules and the measured folding, the size budget, the upload journal format and states, handle-relative containment, and the test list).
Mitigation / rollback
Revert the PR. Nothing on the network changes. Journals already written stay as
*.ant.partialfiles in the upload history, where older clients ignore them. Manifests rejected by the stricter path rules become readable again.🤖 Generated with Claude Code