| title | 20260911 - Fingerprint S3 buckets from a virtual tree; object keys never become local paths |
|---|---|
| description | Download each object to an anonymous temp file, hash it, delete it, and compute the directory fingerprint from (key, sha256) pairs so that no S3 key is ever used as a filename |
| status | Proposed |
| date | 2026-09-11 |
kosli snapshot s3 stops recreating the bucket as a directory on the operator's machine. Each object is downloaded to an anonymous temp file, hashed, and deleted. The fingerprint is computed by digest.VirtualDirSha256 from (key, sha256) pairs, which reproduces digest.DirSha256 byte for byte. Content mode and the metadata mode of #1069 become one pipeline that differs only in where each object's sha256 comes from.
Today getS3DataFromClient writes every object to filepath.Join(tempDir, key) and fingerprints the resulting tree with DirSha256. The object key, which anyone with write access to the bucket controls, therefore names a file on the operator's filesystem.
PR #1155 fenced that key after a privately reported traversal: it rejects .. segments, opens each destination with O_EXCL so two keys cannot share a file, and turns ENOTDIR into a readable error. It closes the reported hole, but the key still shapes a path, so a class of problems remains:
- Two directories differing only in case merge on macOS and Windows. Unicode normalisation on macOS does the same. Both are fingerprint collisions between distinct bucket contents.
- Legitimate keys fail:
...and..directories everywhere, and on Windows alsoCON, any colon, and a leading backslash, purely because Windows would misread the name on disk. - A key component over 255 bytes fails with a bare
ENAMETOOLONG. - The fingerprint depends on the platform the snapshot runs on. A bucket with
A/xanda/y, or withd\e.txt, fingerprints differently on Linux than on macOS or Windows. - Around sixty lines of production code and most of the #1155 test table reason about Windows and macOS filesystem semantics that CI never executes.
Separately, #1069 (--fingerprint-source metadata) built VirtualDirSha256 to reproduce DirSha256 without a filesystem, and its key rule (validateVirtualPath, strict path.Clean equality) disagrees with #1155's on-disk rule in both directions: a//b, ./c.txt and /lead.txt are accepted on disk and rejected virtually; ... is rejected on disk and accepted virtually. A bucket that snapshots today would break when the default flips.
The fingerprint format itself is fixed. An S3 snapshot must match the fingerprint of the deployed directory attested earlier with kosli attest artifact --artifact-type dir, and every existing environment snapshot on the server was computed as DirSha256 of the tree the key layout produced. So the change must preserve, for every bucket that snapshots successfully today, the identical fingerprint and artifact name.
-
The key never touches the filesystem. Each object is downloaded to
os.CreateTemp(tempDir, "object-*"), hashed withFileSha256, closed and removed. Peak disk drops from the bucket size to the in-flight objects. The transfer manager keeps working unchanged because*os.Fileis anio.WriterAt, and so doesFakeS3Client. -
The key becomes a virtual path by one rule, shared by both modes. A key holding a
..segment is rejected first, on the raw key, soa/../bcannot fold silently ontob. The rest ispath.Clean(strings.TrimLeft(key, "/")), rejecting a result of.. The fold is exactly whatfilepath.Joinproduced on Linux, soa//b,./c.txtand/lead.txtland where they do today. Everything else, including...,..,CON, colons and backslashes, is an ordinary name because nothing is created under it. None of this is for safety.DirSha256walks real directories, which can never hold an entry named.,..or the empty string, so the rule keeps virtual fingerprints inside the space an attested directory can match, and preserves the fingerprints existing buckets already have. -
Collisions are data errors that name every key involved. Two keys resolving to one path, and a key under a prefix that is also an object, cannot be represented as a tree. They are detected before the digest package sees them and reported together, capped at ten by
s3KeyProblemsError, with the existing advice to use--exclude-regexor narrow the include filter. -
A root
.kosli_ignoreis honoured virtually. Its rules are parsed bydigest.ParseIgnoreRules, the same readingDirSha256gives the file, and resolved bydigest/virtualglob.go, which reproducesfilepathx.Glob,filepath.Globandfilepath.Walkstep for step over the virtual tree rather than reimplementing what the globs appear to mean. That is what keeps their quirks identical: a literal**/xnever matches at the root because the pieces concatenate to a double slash,**/*.logdoes, and excludinglogs/*leaves an empty directory whose name is still hashed. Exclusion therefore runs inside the tree walk, not by filtering the file list. The ignore file can never exclude itself, as inDirSha256.digest.FilesNeedingContentshares that walk so excluded objects are not downloaded at all, andVirtualDirSha256refuses a tree that needs a digest it was not given, so a skipped download can never leak into a fingerprint. Equivalence withDirSha256on a materialised tree is asserted for every rule shape inTestVirtualIgnoreTestSuite. -
Downloads may run in parallel, behind a fixed worker pool and a bytes-in-flight budget derived from listing sizes, with results written by index for determinism and a cancellable context so the first transport error stops in-flight multipart downloads. This is a performance property, not a safety one, and is delivered separately from the change this record describes; see #1167. Until it lands, downloads are sequential, exactly as before, and peak temp disk is one object.
-
The switch is pinned, not argued.
TestPinnedFingerprintsininternal/awsholds fingerprints of representative fake buckets recorded against the key-layout implementation before it was replaced: unusual key shapes, a.sorting before a/, nested prefixes with folder markers, and a single object with its basename as artifact name. It was green before the switch and must stay green after it, alongsideTestGetS3DataFromClientKeepsTodaysLayoutForUnusualKeysfrom #1155.
The attest side is unchanged: kosli attest artifact --artifact-type dir still runs DirSha256 on a real directory, and that defines the format. The snapshot side re-derives the same value from the bucket. Three guarantees follow, each with the test that holds it:
- Snapshot equals attestation.
VirtualDirSha256of the bucket's(path, sha256)pairs equalsDirSha256of the directory those objects were uploaded from, and a single object equalsFileSha256plus its basename. Held by the materialise-then-compare tests ininternal/digest(TestVirtualDirTestSuite,TestVirtualIgnoreTestSuite) and byTestMatchesAttestedDirectoryininternal/aws, which hashes a directory, uploads its files to the fake bucket and snapshots it. - Snapshot equals its own history. Every bucket that snapshotted successfully under the key-layout implementation keeps its fingerprint and artifact name, because the fold rule is what
filepath.Joindid. Held byTestPinnedFingerprintsandTestGetS3DataFromClientKeepsTodaysLayoutForUnusualKeys. - No object is lost silently. Every key S3 accepts is representable, whatever the operating system, because the key never becomes a path. The only rejections are keys that cannot form a directory tree at all: a
..segment, two keys folding onto one path, and an object that is also a prefix. S3 permits those, no filesystem does, so no attested directory could match them. Each rejection fails the snapshot and names every key involved; the manifest is never shortened to make a fingerprint. Held byTestEveryS3KeyIsRepresentable, which generates keys over S3's full character set, andTestDownloadsExactlyTheContributingObjects, which asserts the downloaded set equals the listed objects minus markers and exclusions.
- Keep #1155's fenced layout and add rules for the remaining cases. Each remaining case (case folding, Unicode normalisation, component length) needs another platform-specific rule and none can be exercised in Linux CI. The layout is the cause, so fencing it further does not converge.
- Temp files alone, still hashing the on-disk tree. Not viable:
DirSha256hashes every entry's basename, so renaming files changes the fingerprint. - A CRC64- or ETag-derived fingerprint to avoid downloading. S3 stores a full-object CRC64NVME for every object uploaded since December 2024, which makes it tempting. Rejected: CRC64 is linear and trivially forgeable, so an attacker who can write the bucket can replace approved content while keeping the fingerprint, which defeats the purpose of a compliance fingerprint. Hashing the CRC with SHA256 adds no resistance. ETag is MD5 of parts for multipart uploads and depends on client chunk size.
- Incremental snapshots keyed on stored checksums. Only
VersionIdis a trustworthy skip signal and only with versioning enabled; the design needs persisted state the stateless CLI does not have. Left for a separate decision.
- The fingerprint is the same on every operating system, with Linux semantics. A snapshot run on Windows or macOS against a bucket with case-colliding or backslash keys moves to the Linux value. The same holds for a backslash inside an ignore rule: the virtual glob reads it as an escape, as
filepath.Globdoes on Linux, where on Windowsfilepath.Globreads it as a separator, so such a rule now resolves differently for an S3 snapshot than forattest artifact --artifact-type dirrun on the same Windows machine. This is a correction and is release-noted. localPathForS3Key,filepath.IsLocal, theO_EXCLopen, theENOTDIRbranch,containsSingleFileand the platform-conditional tests from #1155 are deleted. No code in the S3 path branches on the operating system any more. The codebase does not get smaller, though: the virtual tree, the key rule with its collision reporting, and above all the faithful simulation offilepathx.Globadd several hundred lines, most of them owed to reproducing.kosli_ignoresemantics exactly. That is the price of the compatibility contract, paid once and shared with #1069.- The bucket's ignore file is recognised by the exact key
.kosli_ignore. On disk,ignoreFilePathInTreealso accepted a case-folded spelling such as.KOSLI_IGNOREwhere the operator's filesystem folded case, so on macOS or Windows such a bucket had its rules applied; now it does not, its exclusions stop applying and its fingerprint moves to the Linux value. Same correction as the key case above, and release-noted with it. ...,..,CON, colon and backslash keys snapshot again...segments and colliding keys remain errors and now name every key involved.- Excluded and ignored objects are not downloaded. Each object's temp file is removed once hashed, so peak temp disk falls from the whole bucket to one object, and to the configured budget once #1167 lands.
- #1069 rebases onto the shared layer: metadata mode becomes a sha256 source plugged into the same list, normalise, exclude, tree pipeline, its key rule disappears in favour of rule 2, and its rejection of buckets with a root
.kosli_ignorebecomes a download of that one object. - Delivery is two pull requests. The first carries this decision with sequential downloads, so the security-relevant review is not mixed with performance work. The second, #1167, adds parallel downloads, the byte budget and the flags that tune them.