You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The original report below contained a factual error about how fsck --from <s3remote> behaves on exported files, corrected by @joeyh in this comment. fsck does download and hash the object; it is not HEAD-only. See the follow-up retraction comment and the strike-through edits below.
The upload-side observations (no Content-MD5 set, InfoPresent logged unconditionally, ETag never compared) are correct. The fsck-side framing was wrong.
Summary
Extra disclaimer as not posted from @yarikoptic-gitmate: it is not yet curated analysis by claude code, hinted on HI-to-HI communication, and my prior experiences in designing uploads for dandiarchive.org
Interest triggered by a discussion with OpenNeuro folk (cc @effigies) about relying on git annex fsck to guarantee that files in an S3-backed archive are actually intact. In the current S3 export code path, fsck --from <s3remote> cannot deliver that guarantee — see below.[CORRECTION: fsck does verify content by download+hash even for exports — see Command/Fsck.hs:163-217. The remaining concern is narrower: cost of periodic re-fsck on archive-scale exports, and the window between upload and first fsck during which InfoPresent is trusted.]
When git annex export uploads a file to an S3 remote (exporttree=yes), git-annex marks the file as present on the remote as soon as the HTTP upload returns without an exception. There is no end-to-end integrity check between the file's content and what actually landed in the bucket:
The ETag returned by putObject / postCompleteMultipartUpload is captured but never compared against anything (Remote/S3.hs:394, Remote/S3.hs:433; discarded at Remote/S3.hs:550 via void $ storeExportS3').
cleanupExport writes InfoPresent to the location log and updates the export DB unconditionally on non-exception return (Command/Export.hs:391-396).
checkPresentExportS3 on the authenticated code path only does a HEAD request and returns true if any object metadata is present — no size, no checksum, no ETag compare (Remote/S3.hs:542-547, Remote/S3.hs:585-587). The public-URL fallback path does verify size via Url.checkBoth u (fromKey keySize k) at Remote/S3.hs:589-590, so the authenticated (common) case is actually weaker than the anonymous one. Because the exported object is keyed by tree-path (not by git-annex key/hash), a bare HEAD cannot detect content mismatch even in principle. [EDIT: this is about checkPresent in isolation — used by whereis-style queries and as fsck's first step. fsck itself does more than HEAD, see the CORRECTION above.]
Only retrieveExportS3 verifies content (Remote/S3.hs:563-573, via verifyKeyContentIncrementally AlwaysVerify) — but by then the false InfoPresent has already been recorded, and other copies may have been dropped based on it. [EDIT: retrieveExportS3 is also what fsck --from invokes, so fsck triggers this verification too. Under --fast mode however, Command/Fsck.hs:207-210 skips the download and reverts to HEAD-only.]
So a partial / truncated / silently-corrupted S3 upload (buggy S3-compatible endpoint, transient network corruption, a botched multipart complete, etc.) can be recorded as a valid copy and only surface as an error much later, when the user tries to fetch it back[EDIT: … surface as an error only on the next full-download step — either a git annex get, or a fsck --from without --fast. During the window between upload and that next full-verify, InfoPresent is trusted for numcopies/whereis accounting.]
Realistic failuredataloss path
git annex export <tree> --to s3remote
putObject (or postCompleteMultipartUpload) returns HTTP 200 with an ETag; the object in S3 is truncated / corrupted / a leftover from a prior failed part upload.
storeExportS3 returns normally → cleanupExport records InfoPresent for that key at the S3 remote's UUID.
git annex fsck --from s3remote → checkPresentExportS3 → HEAD → object exists → "OK".[CORRECTION: git annex fsck --from s3remote (without --fast) downloads the object via retrieveKeyFile and hashes it against the key. Corruption is detected here — this step catches the fault. Under --fast, however, only the HEAD check runs and corruption goes undetected. So the failure path applies to (a) users who don't run fsck between the upload and their next drop-based-on-numcopies decision, and (b) users who run fsck --from --fast.]
User trusts S3 as a valid copy; local content is eventually dropped elsewhere on the strength of that.
Later git annex get --from s3remote finally trips verifyKeyContentIncrementally and discovers the hash mismatch — after the safety margin has already been eroded. [EDIT: or, equivalently, git annex fsck --from s3remote (non---fast) discovers it, at the cost of a full download of the object.]
Related but distinct from the well-known "S3 doesn't guarantee the ETag equals MD5 for encrypted / multipart objects" caveat — the issue here is that git-annex isn't attempting any comparison at all.
Suggested mitigations
Both strategies below are used in production by dandi/dandi-cli. Either or both could apply to git-annex's S3 export path:
Ask S3 to validate on its side via Content-MD5 / SigV4 payload hash. For single-part upload, git-annex knows (or can compute during upload) the MD5 of the object and can pass it as Content-MD5; S3 will reject the PUT with BadDigest if the body doesn't match. dandi-cli does exactly this for the small-file path — see dandi/files/zarr.py around L940 in _upload_zarr_file:
The aws Haskell library exposes poContentMD5 on PutObject for exactly this. Same idea for S3.uploadPart.
Reconstruct and compare the multipart ETag client-side. git-annex fully controls the multipart flow (Remote/S3.hs:396-435) — it can accumulate the per-part MD5s during upload, compute md5(concat(part_md5s)) and append -<partcount>, and compare that against S3.cmurETag returned by postCompleteMultipartUpload. Mismatch → fail the export before cleanupExport runs.
This is exactly what dandi-cli's DandiETag does — the algorithm lives in dandischema/digests/dandietag.py (DandiETag class at L96, as_str() at L132-140: f"{md5(concat(md5(part_i))).hexdigest()}-{len(parts)}"), and the comparison against the server-returned ETag is in dandi/files/bases.py L373-471, boiling down to:
etagger=get_dandietag(self.filepath) # bases.py:374filetag=etagger.as_str() # bases.py:375
...
final_etag=rxml.findtext(f"{ns}ETag").strip('"') # bases.py:463-465iffinal_etag!=filetag:
# bail out — server and client disagree on final ETag (bases.py:466-471)
Per-part sizes are also cross-checked against etagger.get_part(...) before upload (bases.py around L681).
Note that mitigation (a) subsumes (b) at the S3-transit level: if each part's upContentMD5 is validated server-side, the combined ETag mismatch cannot occur (barring post-ack corruption on the S3 side, which is a different threat model). Recommend (a) alone for the S3 code path — it's a smaller, more robust patch.
Library availability check:git-annex.cabal line 296 requires aws >= 0.24.1; stack.yaml currently resolves aws-0.25.2. Both poContentMD5 :: Maybe (Digest MD5) on PutObject and upContentMD5 :: Maybe (Digest MD5) on UploadPart are present at that version, so mitigation (a) needs no library bump — just wiring an MD5 computed alongside the existing stream through httpBodyStorer / handlePopper. The current putObject helper at Remote/S3.hs:1107-1116 explicitly leaves poContentMD5 = Nothing (populates only storage class / metadata / auto-make-bucket / ACL / tagging).
The correct semantic fix, longer-term: neither (a) nor (b) validates against the git-annex key hash. They only prove "what S3 stored matches what we sent" — not "what we sent matches the key". A full end-to-end fix would hash the file with the key's own backend (SHA256E, etc.) during the upload stream and refuse to record InfoPresent on a mismatch. This is more invasive but is what the key-hash contract implies.
Even without any of the above, at minimum: if verification isn't structurally possible for a given endpoint/backend, refuse to record InfoPresent after export without an explicit verify-roundtrip (or gate on an option), so users at least have a way to opt into "upload-then-verify" semantics.
Impact
Silent-corruption exposure for any git-annex user relying on an S3 remote as a durable copy of an exported tree. Especially concerning for S3-compatible (non-AWS) endpoints where transport-layer integrity guarantees are weaker than on AWS proper. And — bringing it back to the trigger — fsck --from <s3remote>cannot currently be used to reassure downstream users (e.g. OpenNeuro) that archived files are intact; it only confirms that some object with the right name exists.[CORRECTION: fsck DOES download and hash for exports — it can be used to reassure downstream users that archive files are intact, at the cost of downloading the archive (or the delta since the last incremental-fsck pass). The remaining concern is the download cost for archive-scale exports and the window between upload and the next full-verify.]
L386-395 single-part: S3.porETag resp returned but never validated.
L396-435 multipart: per-part S3.uprETag and final S3.cmurETag returned but never validated.
L549-561storeExportS3: void $ storeExportS3' — ETag and version-id both discarded.
Location log — Command/Export.hs:
-- Command/Export.hs:391-396
cleanupExport r db ek loc sent =do
liftIO $ addExportedLocation db ek loc
when (sent &¬ (isGitShaKey ek)) $
logChange NoLiveUpdate ek (uuid r) InfoPresentreturnTrue
-- Remote/S3.hs:542-547
checkKeyHelper' info h o limit = liftIO $ runResourceT $do
rsp <- sendS3Handle h req
extractFromResourceT (isJust $S3.horMetadata rsp)
where
req = limit $S3.headObject (bucket info) o
-- Remote/S3.hs:585-591
checkPresentExportS3 hv r info k loc = withS3Handle hv $\caseRight h -> checkKeyHelper info h (Left (T.pack $ bucketExportLocation info loc))
...
Retrieval (verifies content; called by git annex get AND by fsck --from in the non---fast case) — Remote/S3.hs:563-573:
retrieveExportS3 hv r info k loc f p =
verifyKeyContentIncrementally AlwaysVerify k $\iv ->
withS3Handle hv $\caseRight h -> retrieveHelper (gitconfig r) info h (Left (T.pack exportloc)) f p iv
...
Fsck-from-remote flow (Command/Fsck.hs:163-217) — performRemote first calls Remote.hasKey (HEAD-only for exports), then downloads via Remote.retrieveKeyFile … (RemoteVerify remote) at line 214. For exports, retrieveKeyFile is adjusted at Remote/Helper/ExportImport.hs:239-244 to route through retrieveFromImportOrExport → retrieveExport above. --fast mode skips the download at Command/Fsck.hs:207-210.
Related sites in the same code path
Adjacent places that inherit or extend the same "trust the HTTP response" pattern — worth touching in the same fix:
storeExportWithContentIdentifierS3 (Remote/S3.hs:796-807) does retain the ETag, but only as an opaque content-identifier — never compared against an expected content hash. A corrupt upload therefore gets a stable-but-meaningless CID; checkPresentExportWithContentIdentifierS3 (Remote/S3.hs:819-825) will then succeed forever on a HEAD that matches that same corrupt ETag via limitHeadToContentIdentifier. Also note the dead-branch at lines 797-799: both versioning info and otherwise branches do the same go — probably an unfinished intention to differentiate versioned vs unversioned verification (versioned case ought to be able to do a version-id round-trip verify).
Annex-object → export rename shortcut (Command/Export.hs:372-388, tryrenameannexobject): when the annex-object is already known to be present on the remote, git-annex issues a server-side S3 copy (renameExport) instead of re-uploading. It then calls addExportedLocation unconditionally on success (line 382). This inherits any pre-existing corruption at the source key without noticing.
No fsck / verify hook currently touches the export path (grep -n 'export\|Export' Command/Fsck.hs → nothing; Logs/Export.hs and Logs/Export/Pure.hs manage the export tree log without triggering content verification). So there is currently no post-hoc way for fsck --from <s3remote> to catch a corrupt export short of downloading and re-hashing.[CORRECTION: downloading and re-hashing is exactly what fsck does. The routing goes via retrieveKeyFile (adjusted for exports by Remote/Helper/ExportImport.hs:239-244), not via a fsck-specific export hook.]
Notes / caveats
With SSE-KMS / SSE-C / CSE, the returned ETag is not the MD5 of the plaintext object, so the client-side ETag reconstruction (mitigation b) needs to account for that. The server-side Content-MD5 header (mitigation a) still works fine — S3 verifies the pre-encryption body.
InfoPresent is skipped for isGitShaKey ek (Command/Export.hs:394), i.e. for non-annexed git blobs being exported. For annexed keys — the case that matters for durability guarantees — InfoPresent is written unconditionally on any non-exception return of the storer.
The same reasoning likely applies to other export-capable remotes (Remote/WebDAV.hs, Remote/Rclone.hs, …) — but this issue is scoped to the S3 code path we actually read.
Reported after a static code review of the S3 export path (Remote/S3.hs, Command/Export.hs) at master ccf99dd4fe, followed by an independent second-pass verification. Not a live reproducer. Filed with the ai-uncurated label per repo convention; please double-check line numbers / logic before treating as authoritative. Edited 2026-08-01 to correct incorrect claims about fsck --from behaviour after @joeyh's correction; see the retraction comment for details.
CORRECTION (2026-08-01)
Important
The original report below contained a factual error about how
fsck --from <s3remote>behaves on exported files, corrected by @joeyh in this comment. fsck does download and hash the object; it is not HEAD-only. See the follow-up retraction comment and the strike-through edits below.The upload-side observations (no
Content-MD5set,InfoPresentlogged unconditionally, ETag never compared) are correct. The fsck-side framing was wrong.Summary
Extra disclaimer as not posted from @yarikoptic-gitmate: it is not yet curated analysis by claude code, hinted on HI-to-HI communication, and my prior experiences in designing uploads for dandiarchive.org
Interest triggered by a discussion with OpenNeuro folk (cc @effigies) about relying on
git annex fsckto guarantee that files in an S3-backed archive are actually intact.In the current S3 export code path,[CORRECTION: fsck does verify content by download+hash even for exports — seefsck --from <s3remote>cannot deliver that guarantee — see below.Command/Fsck.hs:163-217. The remaining concern is narrower: cost of periodic re-fsck on archive-scale exports, and the window between upload and first fsck during whichInfoPresentis trusted.]When
git annex exportuploads a file to an S3 remote (exporttree=yes), git-annex marks the file as present on the remote as soon as the HTTP upload returns without an exception. There is no end-to-end integrity check between the file's content and what actually landed in the bucket:ETagreturned byputObject/postCompleteMultipartUploadis captured but never compared against anything (Remote/S3.hs:394,Remote/S3.hs:433; discarded atRemote/S3.hs:550viavoid $ storeExportS3').cleanupExportwritesInfoPresentto the location log and updates the export DB unconditionally on non-exception return (Command/Export.hs:391-396).checkPresentExportS3on the authenticated code path only does aHEADrequest and returns true if any object metadata is present — no size, no checksum, no ETag compare (Remote/S3.hs:542-547,Remote/S3.hs:585-587). The public-URL fallback path does verify size viaUrl.checkBoth u (fromKey keySize k)atRemote/S3.hs:589-590, so the authenticated (common) case is actually weaker than the anonymous one. Because the exported object is keyed by tree-path (not by git-annex key/hash), a bareHEADcannot detect content mismatch even in principle. [EDIT: this is aboutcheckPresentin isolation — used bywhereis-style queries and as fsck's first step. fsck itself does more than HEAD, see the CORRECTION above.]retrieveExportS3verifies content (Remote/S3.hs:563-573, viaverifyKeyContentIncrementally AlwaysVerify) — but by then the falseInfoPresenthas already been recorded, and other copies may have been dropped based on it. [EDIT:retrieveExportS3is also whatfsck --frominvokes, so fsck triggers this verification too. Under--fastmode however,Command/Fsck.hs:207-210skips the download and reverts to HEAD-only.]So a partial / truncated / silently-corrupted S3 upload (buggy S3-compatible endpoint, transient network corruption, a botched multipart complete, etc.) can be recorded as a valid copy and
only surface as an error much later, when the user tries to fetch it back[EDIT: … surface as an error only on the next full-download step — either agit annex get, or afsck --fromwithout--fast. During the window between upload and that next full-verify,InfoPresentis trusted for numcopies/whereis accounting.]Realistic
failuredataloss pathgit annex export <tree> --to s3remoteputObject(orpostCompleteMultipartUpload) returns HTTP 200 with an ETag; the object in S3 is truncated / corrupted / a leftover from a prior failed part upload.storeExportS3returns normally →cleanupExportrecordsInfoPresentfor that key at the S3 remote's UUID.[CORRECTION:git annex fsck --from s3remote→checkPresentExportS3→HEAD→ object exists → "OK".git annex fsck --from s3remote(without--fast) downloads the object viaretrieveKeyFileand hashes it against the key. Corruption is detected here — this step catches the fault. Under--fast, however, only the HEAD check runs and corruption goes undetected. So the failure path applies to (a) users who don't runfsckbetween the upload and their next drop-based-on-numcopies decision, and (b) users who runfsck --from --fast.]git annex get --from s3remotefinally tripsverifyKeyContentIncrementallyand discovers the hash mismatch — after the safety margin has already been eroded. [EDIT: or, equivalently,git annex fsck --from s3remote(non---fast) discovers it, at the cost of a full download of the object.]Related but distinct from the well-known "S3 doesn't guarantee the ETag equals MD5 for encrypted / multipart objects" caveat — the issue here is that git-annex isn't attempting any comparison at all.
Suggested mitigations
Both strategies below are used in production by dandi/dandi-cli. Either or both could apply to git-annex's S3 export path:
Ask S3 to validate on its side via
Content-MD5/ SigV4 payload hash. For single-part upload, git-annex knows (or can compute during upload) the MD5 of the object and can pass it asContent-MD5; S3 will reject the PUT withBadDigestif the body doesn't match. dandi-cli does exactly this for the small-file path — seedandi/files/zarr.pyaround L940 in_upload_zarr_file:The
awsHaskell library exposespoContentMD5onPutObjectfor exactly this. Same idea forS3.uploadPart.Reconstruct and compare the multipart ETag client-side. git-annex fully controls the multipart flow (
Remote/S3.hs:396-435) — it can accumulate the per-part MD5s during upload, computemd5(concat(part_md5s))and append-<partcount>, and compare that againstS3.cmurETagreturned bypostCompleteMultipartUpload. Mismatch → fail the export beforecleanupExportruns.This is exactly what dandi-cli's
DandiETagdoes — the algorithm lives indandischema/digests/dandietag.py(DandiETagclass at L96,as_str()at L132-140:f"{md5(concat(md5(part_i))).hexdigest()}-{len(parts)}"), and the comparison against the server-returned ETag is indandi/files/bases.pyL373-471, boiling down to:Per-part sizes are also cross-checked against
etagger.get_part(...)before upload (bases.pyaround L681).Note that mitigation (a) subsumes (b) at the S3-transit level: if each part's
upContentMD5is validated server-side, the combined ETag mismatch cannot occur (barring post-ack corruption on the S3 side, which is a different threat model). Recommend (a) alone for the S3 code path — it's a smaller, more robust patch.Library availability check:
git-annex.caballine 296 requiresaws >= 0.24.1;stack.yamlcurrently resolvesaws-0.25.2. BothpoContentMD5 :: Maybe (Digest MD5)onPutObjectandupContentMD5 :: Maybe (Digest MD5)onUploadPartare present at that version, so mitigation (a) needs no library bump — just wiring an MD5 computed alongside the existing stream throughhttpBodyStorer/handlePopper. The currentputObjecthelper atRemote/S3.hs:1107-1116explicitly leavespoContentMD5 = Nothing(populates only storage class / metadata / auto-make-bucket / ACL / tagging).InfoPresenton a mismatch. This is more invasive but is what the key-hash contract implies.Even without any of the above, at minimum: if verification isn't structurally possible for a given endpoint/backend, refuse to record
InfoPresentafter export without an explicit verify-roundtrip (or gate on an option), so users at least have a way to opt into "upload-then-verify" semantics.Impact
Silent-corruption exposure for any git-annex user relying on an S3 remote as a durable copy of an exported tree. Especially concerning for S3-compatible (non-AWS) endpoints where transport-layer integrity guarantees are weaker than on AWS proper.
And — bringing it back to the trigger —[CORRECTION: fsck DOES download and hash for exports — it can be used to reassure downstream users that archive files are intact, at the cost of downloading the archive (or the delta since the last incremental-fsck pass). The remaining concern is the download cost for archive-scale exports and the window between upload and the next full-verify.]fsck --from <s3remote>cannot currently be used to reassure downstream users (e.g. OpenNeuro) that archived files are intact; it only confirms that some object with the right name exists.Code references (git-annex 10.20260717, master @ ccf99dd)
Upload —
Remote/S3.hs:S3.porETag respreturned but never validated.S3.uprETagand finalS3.cmurETagreturned but never validated.storeExportS3:void $ storeExportS3'— ETag and version-id both discarded.Location log —
Command/Export.hs:Called from
performExport(Command/Export.hs:312-330) on any non-exception return fromstorer.Presence check —
Remote/S3.hs:Retrieval (verifies content; called by
git annex getAND byfsck --fromin the non---fastcase) —Remote/S3.hs:563-573:Fsck-from-remote flow (
Command/Fsck.hs:163-217) —performRemotefirst callsRemote.hasKey(HEAD-only for exports), then downloads viaRemote.retrieveKeyFile … (RemoteVerify remote)at line 214. For exports,retrieveKeyFileis adjusted atRemote/Helper/ExportImport.hs:239-244to route throughretrieveFromImportOrExport→retrieveExportabove.--fastmode skips the download atCommand/Fsck.hs:207-210.Related sites in the same code path
Adjacent places that inherit or extend the same "trust the HTTP response" pattern — worth touching in the same fix:
storeExportWithContentIdentifierS3(Remote/S3.hs:796-807) does retain the ETag, but only as an opaque content-identifier — never compared against an expected content hash. A corrupt upload therefore gets a stable-but-meaningless CID;checkPresentExportWithContentIdentifierS3(Remote/S3.hs:819-825) will then succeed forever on a HEAD that matches that same corrupt ETag vialimitHeadToContentIdentifier. Also note the dead-branch at lines 797-799: bothversioning infoandotherwisebranches do the samego— probably an unfinished intention to differentiate versioned vs unversioned verification (versioned case ought to be able to do a version-id round-trip verify).Command/Export.hs:372-388,tryrenameannexobject): when the annex-object is already known to be present on the remote, git-annex issues a server-side S3 copy (renameExport) instead of re-uploading. It then callsaddExportedLocationunconditionally on success (line 382). This inherits any pre-existing corruption at the source key without noticing.No fsck / verify hook currently touches the export path ([CORRECTION: downloading and re-hashing is exactly what fsck does. The routing goes viagrep -n 'export\|Export' Command/Fsck.hs→ nothing;Logs/Export.hsandLogs/Export/Pure.hsmanage the export tree log without triggering content verification). So there is currently no post-hoc way forfsck --from <s3remote>to catch a corrupt export short of downloading and re-hashing.retrieveKeyFile(adjusted for exports byRemote/Helper/ExportImport.hs:239-244), not via a fsck-specific export hook.]Notes / caveats
Content-MD5header (mitigation a) still works fine — S3 verifies the pre-encryption body.InfoPresentis skipped forisGitShaKey ek(Command/Export.hs:394), i.e. for non-annexed git blobs being exported. For annexed keys — the case that matters for durability guarantees —InfoPresentis written unconditionally on any non-exception return of the storer.Remote/WebDAV.hs,Remote/Rclone.hs, …) — but this issue is scoped to the S3 code path we actually read.Reported after a static code review of the S3 export path (
Remote/S3.hs,Command/Export.hs) at masterccf99dd4fe, followed by an independent second-pass verification. Not a live reproducer. Filed with theai-uncuratedlabel per repo convention; please double-check line numbers / logic before treating as authoritative. Edited 2026-08-01 to correct incorrect claims aboutfsck --frombehaviour after @joeyh's correction; see the retraction comment for details.