Skip to content

feat(snapshot s3): fingerprint buckets from metadata - #1069

Draft
mbevc1 wants to merge 4 commits into
mainfrom
20260801_s3_metadata
Draft

mbevc1 wants to merge 4 commits into
mainfrom
20260801_s3_metadata

Conversation

@mbevc1

@mbevc1 mbevc1 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Adds --fingerprint-source content|metadata to kosli snapshot s3.

Today the command downloads every contributing object and hashes it. For a large bucket that
means full egress and a SHA256 pass over every byte, on top of the temp disk the download
budget allows. With --fingerprint-source metadata the command reads the SHA256 checksum S3
already stores for each object instead, via HeadObject with ChecksumMode enabled — no
download, no hashing.

Default is content, so existing behaviour is unchanged.

Rebased onto the shared pipeline from #1180 and #1191

This is the landing the ADR (docs/adr/20260911-s3-fingerprint-from-virtual-tree.md)
described: metadata mode is a digest source plugged into the same list, normalise, exclude,
tree pipeline
as content mode. The pipeline gained one seam, s3DigestSource, and content
mode became its first implementation; nothing else about content mode changed, which
TestPinnedFingerprints, TestMatchesAttestedDirectory and the parallel-download suite hold.

Because the pipeline is shared:

  • The key rule is shared. Keys fold exactly as content mode folds them (a//b, /lead.txt,
    ./c.txt all land where filepath.Join put them), and the same collisions are reported the
    same way. This PR's earlier stricter rule is gone, as the ADR called for.
  • A root .kosli_ignore is applied, not rejected. It is downloaded — one object — as in
    content mode, its rules decide what contributes, and objects the rules exclude are never
    fetched and so need no checksum. The earlier "reject the bucket" behaviour is gone.
  • The fingerprint is byte for byte the same. TestMatchesContentMode compares the whole
    S3Data from both sources over the same fake buckets, including one with ignore rules and one
    with unusual keys, and the pinned fingerprints from main are re-derived from checksums alone.

Conditions a bucket must meet

  • Every contributing object needs a full-object SHA256 checksum. S3 only stores one when the
    upload asked for it (aws s3api put-object --checksum-algorithm SHA256).
  • Composite (multipart) checksums are rejected. They hash the part checksums rather than the
    object content. Detection keys off the observed COMPOSITE type and the -N suffix — the
    latter being what the SDK's own response validation uses — so a full-object SHA256 is accepted
    however it was uploaded. The fix offered is aws s3api copy-object --checksum-algorithm SHA256, which collapses the parts in place without the original file.

Both are properties of the object, not the connection, so the fan-out collects them and one run
names every object that needs fixing, capped like key problems are (ten). A transport error
still stops the run at once.

It does not save permissions: AWS requires s3:GetObject to read a checksum, the same one
downloading needs. The help says so.

Commits

  1. feat(aws): HeadObject on the seam; fake stores checksums sparsely and withholds them
    unless ChecksumMode is set, as S3 does; contract sub-tests (skip against real AWS until a
    checksum-bearing object exists — not in kosli-cli-public, whose fingerprint TestGetS3Data
    pins).
  2. refactor(snapshot s3): s3DigestSource; pure refactor, existing suites are the proof.
  3. feat(snapshot s3): the flag and the metadata source.
  4. docs(snapshot s3): help text.

Not run here: SnapshotS3TestSuite (needs the local server) — five new cases were added with
goldens mirroring the pinned error text.

Checklist

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @mbevc1's task in 2m 55s —— View job


Follow-up review of new commits (b384eb79..fa347112)

  • Fetch previous review findings on PR feat(snapshot s3): fingerprint buckets from metadata #1069
  • Diff the new commits against origin/main (whole PR was rebased today, so BEFORE sha is unreachable — reviewed git diff origin/main...HEAD, scoped to the 4 commits that make up this PR)
  • Re-check each previous finding against the current code
  • Scan changed lines for new bugs/regressions

Previous findings — status

  1. Unclean S3 keys (a//b, /foo) hard-erroring in metadata mode while content mode silently collapses them (internal/digest/virtualdir.go:261) — Resolved (superseded). That file is gone from this PR entirely. Metadata mode now runs the same shared s3DigestSource pipeline as content mode (internal/aws/aws.go), so keys fold identically in both modes via the existing key-collision rules — confirmed by TestMatchesContentMode/unusual_key_shapes in internal/aws/s3_metadata_test.go. No asymmetry remains, nothing further to do here. Closed the inline thread.
  2. One-goroutine-per-object fan-out in fetchObjectChecksums, unbounded for very large buckets (internal/aws/s3_metadata.go) — Resolved. That function no longer exists. Metadata mode now plugs into fetchS3DigestsInParallel (internal/aws/aws.go:747), which already runs a fixed worker pool (min(max(limits.Concurrency,1), len(indexes)) goroutines reading off a work channel) — the exact fix suggested. Goroutine count is bounded by --download-concurrency regardless of bucket size.

New issues in the changed lines: none found. Checked cmd/kosli/snapshotS3.go/root.go (flag wiring + validation), internal/aws/aws.go (the s3DigestSource refactor and worker-pool reuse), internal/aws/s3_metadata.go (checksum decoding/validation, composite-checksum detection), internal/aws/fake_s3.go + s3_contract_test.go (HeadObject fake/contract), and the new/updated tests — all consistent, no duplicated constants, error handling correctly distinguishes transport errors (abort) from per-object unusableChecksumError (collect and report together, capped by the existing shared maxReportedS3KeyProblems).

Both previously open threads are resolved; no new inline comments needed.

Comment thread internal/digest/virtualdir.go
@mbevc1
mbevc1 force-pushed the 20260801_s3_metadata branch from 8aa8a82 to e5e7859 Compare August 1, 2026 22:35
Comment thread internal/aws/s3_metadata.go Outdated
Fingerprinting a bucket from the checksums S3 already stores needs object
metadata, not object content. Add S3HeadAPI to the S3API composite, backed
by the same *s3.Client that already serves listing, and give FakeS3Client a
HeadObject to match.

The fake models stored checksums sparsely, via a Checksums map rather than
deriving them from object bytes: an object uploaded without an explicit
checksum algorithm has none, and that is the common case a caller has to
handle. It also withholds the checksum unless the request sets ChecksumMode,
exactly as S3 does -- a fake that always returned it would hide a caller
that forgets to ask.

The contract tests gain a sha256ChecksumKey parameter and cover metadata
retrieval, the missing-key error, and both sides of the ChecksumMode
behaviour. They skip when no checksum-bearing object is available, which is
the case for kosli-cli-public today: adding one there would change the
golden fingerprints TestGetS3Data pins.
fingerprintS3Objects hard-wired how each object's sha256 is obtained:
download to a temp file and hash. Split that step out as s3DigestSource so
a second source can supply digests without touching the disk, and keep
everything else -- the key rule, the root .kosli_ignore download and its
rules, the tree walk, the parallel fan-out -- in one shared pipeline,
fingerprintS3Tree. Two sources cannot then fingerprint the same bucket
differently, which is the property the ADR asks of metadata mode.

The fan-out charges an object's listed size against the byte budget only
when the source uses the disk; a source that reads metadata owes it
nothing, so its concurrency is bounded by the worker count alone.

fingerprintS3Objects keeps its signature as the content-mode entry point,
so the parallel-download suite is unchanged and, with the pinned and
attested-directory fingerprints, is the proof this is a pure refactor.
…tadata

kosli snapshot s3 downloads every contributing object and hashes it. For a
large bucket that is a full egress and a SHA256 pass over every byte, on
top of the temp disk the download budget allows.

--fingerprint-source metadata reads the SHA256 checksum S3 already stores
for each object instead, with a HeadObject that asks for it. It is a
second digest source plugged into the shared pipeline, so the key rule,
the root .kosli_ignore and the tree walk are exactly content mode's and
the fingerprint is byte for byte the same -- the pinned fingerprints and
the attested-directory equality now hold for both sources.

What this does not save is permissions. AWS requires s3:GetObject to read
an object's checksum, the same permission downloading it needs, so the
help text says so rather than letting anyone infer otherwise.

Every contributing object must carry a full-object SHA256 checksum, which
S3 only stores when the upload asked for one. A composite (multipart)
checksum hashes the part checksums rather than the object and is rejected
on both signals S3 gives -- the COMPOSITE type and the "-N" suffix the
SDK's own response validation keys off -- with copy-object as the fix,
which collapses the parts in place without the original file. Such
problems describe the object rather than the connection, so the fan-out
collects them and one run names every object that needs fixing, capped
like key problems are; a transport error still stops the run at once.

The root .kosli_ignore is downloaded as before, whatever the source, since
its rules decide which objects contribute; objects the rules exclude are
never fetched and so need no checksum. A source that reads metadata owes
the byte budget nothing, so its HEADs are bounded by the worker count
alone.

decodeLambdaFingerprint becomes decodeBase64Sha256 now that Lambda's
CodeSha256 is not the only Base64 digest AWS hands us.
Say what --fingerprint-source metadata changes and, as importantly, what
it does not. The pipeline is shared, so keys, .kosli_ignore rules and the
fingerprint itself are the same in both modes; only where each object's
digest comes from differs. The two conditions a bucket must meet -- a
stored full-object SHA256 on every contributing object, and no composite
multipart checksums -- come with the aws command that fixes each.

The obvious assumption is that reading metadata needs weaker permissions
than downloading. AWS requires s3:GetObject for both, so the help says so
plainly rather than leaving the reader to infer a benefit that is not
there.
@mbevc1
mbevc1 force-pushed the 20260801_s3_metadata branch from 6a95885 to fa34711 Compare September 16, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant