Add an upsert overlay op so fixtures survive base-image re-pins - #174
Add an upsert overlay op so fixtures survive base-image re-pins#174stevebeattie wants to merge 3 commits into
Conversation
The offline matrix broke on chainguard-dev#173, the daily base-image re-pin: every detect_openssl fixture failed with overlay apply: adding "etc/ssl/openssl.cnf": path already exists before any scan ran. The newer wolfi-base ships /etc/ssl/openssl.cnf, which the previously pinned image did not — confirmed by inspecting both: 0 occurrences at 7e62cecd, 1 at 103eb3f4. The cause is that no overlay op could express what these fixtures want. AddFile requires the path to be absent, ReplaceFile and AppendFile require it to be present. So a fixture had to bet on whether the base image shipped a path, and the bet expired silently whenever the base was re-pinned. Nothing about the datastream or the checks was wrong; the End-to-end scan, mirror check and sidecar guard all passed on that PR. Add PutFile, which replaces an existing entry's content in place — keeping its header and position — or creates one if absent. mode/uid/gid apply only on creation, since a fixture replacing content is not usually restating permissions. Move all 29 AddFile calls in the fixture matrix onto it. Every one of them means "this file holds this content"; none was relying on the absence precondition for its meaning. The fixtures that do test absence express it by *omitting* the file — fail_missing_ssh_fips_conf leaves the drop-ins out, fail_java_missing_stamp adds a truststore and no stamp — so that behaviour is unchanged. AddFile stays for cases where the base gaining a path should be a loud failure rather than absorbed, and remains in use by CopyFile and PutFile. Verified causally, not just by the suite going green: - against the base chainguard-dev#173 pins, the converted fixtures pass, and reverting them to AddFile reproduces the exact "path already exists" error; - against the base on main, they pass too, so this is not a swap of one bet for the other; - full matrix green at 47 rows, 11 of them detect_openssl, 0 failures; gofmt/vet clean and the mirror check unaffected. TestPutFile covers both directions plus the property that motivated it: the same op applies cleanly whether or not the base ships the path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0xDom-S
left a comment
There was a problem hiding this comment.
🤖: Verdict: APPROVE. PR #174 adds overlay.PutFile (tests/oscap-offline/internal/overlay/overlay.go:141-175) as an upsert op — it replaces the target's content in place when the base tar already holds the path, otherwise delegates to AddFile. All 29 AddFile call sites in fixtures_test.go move onto it, unblocking a red CI matrix caused by base re-pins that add paths fixtures used to assert as absent.
Both safety invariants that matter were verified by execution, not just inspection: path safety (every p.byName key reaching PutFile was already validated by safePath at ingest/AddFile time) and type safety (ReplaceFile → requireReg still returns ErrNotRegular for a non-regular target). overlay has no production importer (only test files import it), so there is no security surface in shipped binaries — all 29 call sites also pass string literals / package-level []byte constants, so no injection surface either.
No Critical Issues were found. The Suggestions below are non-blocking engineering follow-ups; two are worth prioritizing (class-wide alarm loss, and the stale doc.go op list). The following could not be anchored to a changed line in this diff and are recorded here instead:
- tests/oscap-offline/internal/overlay/doc.go:5 — Package doc comment still enumerates the exported op set as "(AppendFile, AddFile, CopyFile, Chown)" and omits the new
PutFile(also pre-existing:ReplaceFile/RemoveFilewere already missing). Adding an exported op is the moment to fix this. - tests/oscap-offline/internal/overlay/doc.go:16-20 — The error-contract paragraph documents which ops return
ErrNotFound/ErrExists/ErrNotRegularbut doesn't mentionPutFile's contract (it can surfaceErrNotRegularvia itsReplaceFilebranch, andErrUnsafePath/ErrExistsvia itsAddFilebranch). - tests/oscap-offline/internal/overlay/overlay.go:111 and :279-288 (pre-existing, outside this diff's changed-line range) —
RemoveFile(p)followed byPutFile(p, …)emits two members namedp:RemoveFiledeletes the entry frombyNamebut leaves the name inp.order, so a subsequentPutFile/AddFilere-add writes the path in both the base-order loop and the added loop. Reproduced empirically. Not introduced by this PR and no fixture currently pairsRemoveFilewith a re-add of the same path (the soleRemoveFilecall is fixtures_test.go:620, with no re-add), butPutFile's doc comment ("gives path the given content whether or not it already exists") actively invites that pairing going forward — worth a one-line caveat in the doc comment. - .github/workflows/offline-tests.yaml:32-37 (unchanged by this PR) —
OSCAP_OFFLINE_REQUIRE: "1"only fails the job on a prerequisite gap (no docker / no datastream / no registry), per its own comment. It does not and cannot detect a fixture that has been silently converted to assert less (see the PutFile-class-wide-alarm-loss suggestion below) — it is not the safety net one might assume it to be for that failure mode. - gpos/xml/scap/ssg/content/ssg-chainguard-xccdf/OvalDefinitions/CertificateAuditTest.xml (unchanged by this PR) — re: the mode/uid/gid-drop concern on the PutFile replace branch: this file's OVAL states carry no
mode/uid/gidpredicate (six<ind:state>refs at :55,62,72,79,83,90 plus a<states>block at :144-163, none checking permissions), so today's fixture conversions are verdict-neutral here. Separately, :64-66 usescheck_existence="none_exist"for the Java truststore, meaning this fixture family's discriminating power also tracks base drift via existence rather than permissions — same class of concern as the alarm-loss suggestion below. - gpos/xml/scap/ssg/content/ssg-chainguard-xccdf/OvalDefinitions/LibraryPermissionsTest.xml:58-60 and .../VarLogPermissionsTest.xml:37-39 (unchanged) — the only OVAL states in this repo that do read ownership/permissions target
/usr/liband/var/log, which are disjoint from theetc/ssl/**/etc/ssh/**paths converted toPutFilein this PR. So the mode/uid/gid-drop trap (see suggestion below) has no live consequence today, but is a real latent trap for the next permissions-checking STIG rule added overetc/ssh/sshd_configor similar. - gpos/xml/scap/ssg/content/ssg-chainguard-xccdf/OvalDefinitions/DetectOpenSslTest.xml:268-272 (unchanged) — same check: its only state (
environmentvariable58_state) does not read file permissions, so it is unaffected by the mode/uid/gid-drop concern today.
| // /etc/ssl/openssl.cnf, and every fixture that had been adding it started | ||
| // failing with ErrExists before any scan ran. | ||
| // | ||
| // Use AddFile instead only where a fixture's point is that the path was absent, |
There was a problem hiding this comment.
🤖: Suggestion — the base-drift alarm is now gone from the entire fixture matrix; keep one canary. This doc comment advises "Use AddFile instead only where a fixture's point is that the path was absent, so that the base gaining it should be a loud failure" — but after this PR, zero fixtures do that. fixtures_test.go now has 29 overlay.PutFile calls and 0 overlay.AddFile calls (e.g. lines 464-465, 479-482, 630-631, 641-642, 653, 854-855, 867-868, 879-882, 893-894, 905-910, 919-920, 932-933). The precedent this PR fixes for etc/ssl/openssl.cnf is removed class-wide for fipsmodule.cnf, ssh_config, sshd_config and the drop-in fixtures simultaneously. This is a defensible, explicitly-argued trade — absence-testing fixtures still express absence by omission and will fail loudly if the base gains the path — but there is no CI control that separately detects reduced discriminating power of a fixture (verified: OSCAP_OFFLINE_REQUIRE=1 in offline-tests.yaml only guards prerequisite gaps, not a degraded assertion). Cheapest mitigation: retain a single AddFile op on one path the base is asserted not to ship (e.g. etc/ssl/fipsmodule.cnf in one fixture), so a re-pin that adds FIPS pieces still trips something.
There was a problem hiding this comment.
Confirmed the count: 29 PutFile, 0 AddFile in fixtures_test.go. AddFile survives only in example_test.go and as PutFile/CopyFile's internal callee, so the diagnosis is exactly right — the alarm is gone class-wide, and OSCAP_OFFLINE_REQUIRE=1 does only guard prerequisite gaps.
I went a different way on the remedy. Keeping one AddFile canary re-arms the fragility inside a fixture whose job is a scan verdict, so the next re-pin turns the matrix red for a reason unrelated to the rule under test — and it only watches whichever single path it names, leaving the other seven converted paths uncovered.
25c9bfb asserts base composition directly instead, in TestBaseImageComposition. Four premise families, each row carrying why it matters so a failure explains itself rather than needing archaeology:
- paths an existence-precondition op needs (
AppendFileonetc/shadow,CopyFile's source,Chownonvar/log) - paths whose absence gives a fixture its discriminating power — the FIPS ssh drop-ins,
etc/ssl/fipsmodule.cnf, and the Java truststore pair behindCertificateAudittst:5'snone_exist. This is the silent class you identified: gaining one of these leaves the fixture green while it tests less. PutFiletargets are absent or regular files (see theErrNotRegularthread)- no FIPS apk packages installed — the other half of what makes
detect_openssl/fail_clean_no_fipsa control
etc/ssl/openssl.cnf is deliberately in the third list only. It is the path that actually drifted, so pinning it present or absent would just re-create the bet PutFile removed; what must hold is that it stays a regular file.
Verified both directions: re-pinned to #173's digest (Dockerfile and the pinned const, which the update-ca-cert workflow updates together via TEST_PIN_FILE), the check stays green — so it is not a new bet. Planting a wrong row in each of the three path lists turns the matching subtest red naming the path, its tar type and the dependent fixture. It needs only a registry pull, not docker or the datastream, so it runs in more environments than the matrix it protects.
| ops: []overlay.Op{ | ||
| overlay.AddFile(javaTrustStorePath, javaTrustStore, 0o444, 0, 0), | ||
| overlay.AddFile(javaStampPath, javaStamp(javaTrustStore), 0o444, 0, 0), | ||
| overlay.PutFile(javaTrustStorePath, javaTrustStore, 0o444, 0, 0), |
There was a problem hiding this comment.
🤖: Suggestion — mode/uid/gid are silently dropped on the PutFile replace branch, leaving this fixture base-dependent in attribute space. PutFile(javaTrustStorePath, javaTrustStore, 0o444, 0, 0) will silently start asserting base-derived permissions instead of 0o444/uid 0/gid 0 the day the base ships this path (verified empirically against overlay.go: on the replace branch, mode/uid/gid args are ignored and the base header's own values are kept). This is verdict-neutral today for the OVAL rules that read this repo's etc/ssl/**/etc/ssh/** fixtures, but sshd_config permissions are a classic STIG rule, so this is a live trap for the next check added over one of the 29 converted paths. Consider applying mode/uid/gid unconditionally on the replace branch (more consistent with "the fixture, not the base, decides"), or pairing PutFile with a Chown/mode op at the sites that specifically care about attributes, and calling this out in overlay.go's doc comment beyond the current "not usually trying to restate permissions".
There was a problem hiding this comment.
Confirmed the behavior — the replace branch keeps the base header's mode/uid/gid and ignores the arguments — and confirmed verdict-neutrality more tightly than "for the rules that read this repo's fixtures": the only OVAL states in the tree that read ownership are LibraryPermissionsTest.xml:59-60 and VarLogPermissionsTest.xml:38-39, targeting /usr/lib and /var/log. All 29 converted paths are under etc/ssl/** or etc/ssh/**, so the sets are disjoint. (Worth noting the grep for this needs user_id/group_id — my first pass searched uid|gid and came back empty, which is misleading.)
I did not take the "apply mode/uid/gid unconditionally" option. It would override ReplaceFile's documented "leaves its header intact" contract, and it would make all 29 sites assert 0o644/0/0 over base files whose real modes may legitimately differ — turning an incidental argument into a silent assertion at every call site. That trades a latent trap for an active one.
Your alternative is the right one and is now what the docs say: a fixture that means to pin an existing entry's attributes says so with Chown. b9e6db2 spells that out in PutFile's doc comment, along with the corollary — no OVAL check should read permissions on a path a fixture only ever PutFiles.
On "live trap for the next check added over one of the 29 paths": agreed, and that is the part a doc comment alone does not catch. TestBaseImageComposition in 25c9bfb is where it now surfaces, since a permissions rule landing on one of these paths would need a base-composition premise to be meaningful, and that file is where such premises are written down.
| // TestPutFile covers both directions of the upsert, plus the property that | ||
| // motivated it: the same op works whether or not the base ships the path, so a | ||
| // fixture stops depending on that and stops breaking when the base is re-pinned. | ||
| func TestPutFile(t *testing.T) { |
There was a problem hiding this comment.
🤖: Suggestion — the re-pin fragility class is narrowed, not closed, and this gap is untested. If a future base ships one of the 29 converted paths as a symlink or directory instead of a regular file, PutFile → ReplaceFile → requireReg will hard-fail with ErrNotRegular — which is exactly the re-pin breakage shape this PR sets out to eliminate (reproduced). TestPutFile here never exercises a non-regular target for the replace branch. Worth one sentence in the PutFile doc comment plus one subtest covering a non-regular base entry at the target path.
There was a problem hiding this comment.
Implemented, and the finding is stronger than stated. You framed it as "if a future base ships one of the 29 converted paths as a symlink or directory" — but the base already ships symlinks inside exactly that directory family:
lrwxrwxrwx etc/ssl/cert.pem -> certs/ca-certificates.crt
lrwxrwxrwx etc/ssl/certs/ca-bundle.crt -> ca-certificates.crt
lrwxrwxrwx etc/pki/tls/certs/ca-bundle.crt -> ../../../ssl/certs/ca-certificates.crt
So the realistic trigger is not a re-pin at all — it is the next CA-bundle fixture written against an alias, which is a natural thing to reach for given SSL_CERT_FILE already points into that directory. None of the 8 distinct converted paths is currently non-regular in either the current or #173's base, so nothing is broken today.
b9e6db2 adds the doc sentence plus a table-driven rejects a non-regular target subtest covering both a directory and a symlink target. Confirmed it is not vacuous: replacing PutFile's replace branch with a direct e.data = bytes.Clone(content) that skips requireReg turns both subtests red with
Apply error = writing content "etc/ssl/openssl.cnf": archive/tar: write too long, want errors.Is ErrNotRegular
which is precisely the deferred tar-write failure requireReg exists to pre-empt.
The class is also now guarded at the base rather than only at the op: TestBaseImageComposition in 25c9bfb asserts every PutFile target is absent or a regular file, so a base that starts shipping one as a link fails there — naming the path and its type — instead of surfacing as ErrNotRegular from whichever fixture ran first.
| t.Errorf("applying PutFile twice differed from applying it once: %d vs %d bytes", len(once), len(twice)) | ||
| } | ||
|
|
||
| // The base bytes must be untouched, or the shared base would drift across subtests. |
There was a problem hiding this comment.
🤖: Suggestion — two small gaps in otherwise strong new tests. (1) TestPutFileIsDeterministic only exercises idempotency on the replace path; the create-then-replace direction (path absent in base) is uncovered. Verified it is byte-identical there too, so the assertion is free to add. (2) This "Apply leaves the base bytes untouched" assertion cannot actually fail through Apply: apply() hands Apply a bytes.NewReader(base) and Apply only reads from it (io.ReadAll copies into a new byte slice) — in practice this just re-asserts buildTar determinism. Harmless and worth keeping, but overselling it as the load-bearing immutability guarantee is misleading; the properties that actually hold are the fresh plan allocated per Apply call, cp := *hdr when copying base headers, and bytes.Clone when copying content in ReplaceFile/AddFile.
There was a problem hiding this comment.
Both taken.
(1) create-branch idempotency. Confirmed byte-identical, so the assertion was free as you said. TestPutFileIsDeterministic is now table-driven over both branches. Worth recording why it is not merely symmetry: the two carry different bookkeeping — replace rewrites an entry in place, while create appends to p.added — so a repeat that appended a second time would emit the path twice rather than once. That is the same failure shape as the RemoveFile bug in the summary thread, and it is now covered from both directions.
(2) The immutability assertion. You are right that it cannot fail through Apply. Verified: Apply takes an io.Reader, io.ReadAll allocates fresh, and a probe confirms the read data does not alias the base backing array — so there is no path by which a caller's slice could be written through that signature. As written it really tested buildTar's determinism.
I dropped it rather than keeping it, because a green assertion that cannot go red is exactly the thing this PR should not be adding. TestApplyReusesABaseSafely asserts the two properties that genuinely can fail instead — no per-run state shared between calls, and ops not retaining and then mutating the caller's content slice — and its comment records that the third guard you named (cp := *hdr) is structural and not observable from outside a single Apply call, so it is documented rather than fake-asserted.
The PR description had billed the base-untouched assertion as the load-bearing guarantee, which was the same overstatement. I have corrected that section rather than leaving the claim standing in the description and only walking it back down here.
| // restate permissions. | ||
| func PutFile(path string, content []byte, mode int64, uid, gid int) Op { | ||
| return func(p *plan) { | ||
| if _, ok := p.byName[path]; ok { |
There was a problem hiding this comment.
🤖: Optional hardening — hoist the path-safety check to the top of PutFile. This line reads p.byName[path] before any explicit validation in PutFile itself. It is safe today only because of an unstated closed-set invariant: every key that can be in p.byName was already gated through safePath at base-ingest time (overlay.go:243) or at AddFile's own explicit check (overlay.go:119-122) — PutFile's replace branch never adds a new, unvalidated key. Adding an explicit if !safePath(path) at the top of PutFile (line 164) would make this guarantee local and self-evident instead of emergent from two other functions, at zero runtime cost. (Note: a prior automated reviewer claimed safePath is called "before either branch runs" inside PutFile — that's not accurate as written; the guarantee currently holds only via the invariant described above, which is exactly why making it explicit here is worthwhile.)
There was a problem hiding this comment.
Implemented in b9e6db2 — PutFile now checks safePath before touching p.byName, so the guarantee is local instead of emergent from base ingest (overlay.go:243) plus AddFile (:119-122).
Being explicit about what this is and is not: it is not a bug fix. I mutation-tested it by removing the hoist again, and the suite stays green — the create branch is still covered by AddFile's own check, and the replace branch cannot see an unvalidated key because nothing can put one in byName. So the closed-set invariant you describe does hold, and the check is defense-in-depth that makes it self-evident at zero runtime cost, exactly as you framed it. I have not added a test that pretends otherwise, since there is no reachable input that distinguishes the two versions.
Also confirming your correction of the earlier automated reviewer: safePath was not called before either branch ran. The replace branch indexed byName directly with no validation in PutFile itself. That reading was wrong and yours matches the code as written.
…dges Review of chainguard-dev#174 surfaced that RemoveFile dropped a path from the entry map but left its name in the base ordering, so a later op re-adding the same path made Apply write it twice: once from the base-order loop and again from the added loop. The result is a fixture tar with a duplicated member that no op reports as an error. Pre-existing, and not reachable today -- the sole RemoveFile call site never re-adds -- but PutFile's contract ("whether or not it already exists") actively invites the pairing, so leaving it as a documented footgun was the worse option. RemoveFile now drops the name from the ordering too, so a re-add emits one member, positioned with the added entries. Three edges of PutFile are now pinned rather than left to be discovered: - A non-regular target fails with ErrNotRegular. This is the same re-pin breakage shape PutFile set out to remove, and it is not hypothetical for the paths fixtures mutate: the base ships etc/ssl/cert.pem and etc/ssl/certs/ca-bundle.crt as symlinks to ca-certificates.crt, so a future CA-bundle fixture targeting an alias lands here. Reverting requireReg turns both new subtests red with "archive/tar: write too long" -- the later write failure requireReg exists to pre-empt. - Idempotency now covers the create branch, not just replace. The two carry different bookkeeping: replace rewrites in place, while create appends to the added list, and a repeat that appended twice would emit the path twice. - safePath is checked in PutFile itself. This is defense-in-depth, not a bug fix: the create branch was already covered by AddFile and the replace branch cannot see an unvalidated key, so removing the check leaves the tests green. It makes a guarantee that was emergent from two other functions local and self-evident instead. TestApplyReusesABaseSafely replaces the "Apply leaves the base bytes untouched" assertion, which could not fail: Apply takes an io.Reader and only copies out of it, so it cannot write to a caller's slice through that signature, and the assertion really tested buildTar's determinism. The falsifiable properties -- no per-run state shared between calls, and ops not retaining the caller's content slice -- are asserted instead, with a comment recording that the header-copy guard is structural. doc.go's op list had drifted: it named four of seven ops, omitting ReplaceFile and RemoveFile alongside the new PutFile, and its error contract did not cover PutFile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of chainguard-dev#174 raised that converting all 29 AddFile calls to PutFile removes the base-drift alarm from the entire fixture matrix at once: the ErrExists that fired when the base gained etc/ssl/openssl.cnf was crude, but it was the only thing that noticed. Afterwards nothing did, and the failure mode inverted from "CI breaks with a confusing error" to "a fixture quietly asserts less than its name claims". The suggested mitigation was to keep one AddFile as a canary. That works, but it re-arms the fragility inside a fixture whose job is a scan verdict, so a re-pin would turn the matrix red for a reason unrelated to the rule under test -- and it only watches whichever single path it happens to name. Asserting base composition directly covers the whole surface instead, and fails where the cause is rather than as a downstream verdict flip. Four premises, each row carrying the reason it matters so the failure explains itself: - Paths the fixtures require, because an existence-precondition op targets them (AppendFile on etc/shadow, CopyFile's source, Chown on var/log). - Paths whose ABSENCE gives a fixture its discriminating power -- the FIPS ssh drop-ins, etc/ssl/fipsmodule.cnf, the Java truststore pair that CertificateAudit tst:5 checks with none_exist. This is the silent class: gaining one of these leaves the fixture green. - PutFile targets are absent or regular files, never a directory or symlink. Not theoretical: the base ships etc/ssl/cert.pem and etc/ssl/certs/ca-bundle.crt as symlinks to ca-certificates.crt, so the next CA-bundle fixture that targets an alias hits ErrNotRegular. - No FIPS apk packages installed, which is the other half of what makes detect_openssl/fail_clean_no_fips a control. etc/ssl/openssl.cnf sits only in the third list, deliberately. It is the path that actually drifted, so pinning it present or absent would just re-create the bet PutFile removed; what must hold is that it stays a regular file. Verified by pointing the pin at chainguard-dev#173's digest: the check stays green across the re-pin, while planting a wrong row in each of the three path lists turns the matching subtest red with a message naming the path, its type and the dependent fixture. Only a registry pull is needed, not a container runtime or the datastream, so this runs in more environments than the matrix it protects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed
|
What broke
The offline matrix failed on #173, the daily base-image re-pin. Every
detect_opensslfixture errored before any scan ran:The newer
wolfi-baseships/etc/ssl/openssl.cnf; the previously pinned imagedid not. Confirmed by inspecting both:
etc/ssl/openssl.cnf7e62cecd(main)103eb3f4(#173)Nothing about the datastream or the checks was wrong —
End-to-end scan, themirror check, the lint gate and the sidecar guard all passed on that PR.
The defect is an API gap, not a bad fixture
No overlay op could express "this file holds this content":
AddFileReplaceFileAppendFileSo every fixture was implicitly betting on whether the base image ships a path,
and the bet expired silently whenever the base was re-pinned.
openssl.cnfjustwent first —
fipsmodule.cnf,ssh_config,sshd_configand the FIPS drop-inswere all equally exposed.
Swapping to
ReplaceFilewould only invert the bet: it would break the moment abase dropped a path.
Change
PutFilereplaces an existing entry's content in place — keeping its header andposition in the tar — or creates one if absent.
mode/uid/gidapply only oncreation, since a fixture replacing content is not usually restating permissions.
All 29
AddFilecalls in the fixture matrix move onto it. Each means "this fileholds this content"; none relied on the absence precondition for its meaning. The
fixtures that do test absence express it by omitting the file
(
fail_missing_ssh_fips_confleaves out the drop-ins,fail_java_missing_stampadds a truststore and no stamp), so that behaviour is unchanged.
AddFilestaysfor cases where a base gaining a path should fail loudly, and remains in use by
CopyFileandPutFile.Verified causally, not just green
AddFilereproduces the exactpath already existserror.other.
detect_openssl, 0 failures.gofmt/vetclean, mirror check unaffected.TestPutFilecovers both directions plus the property that motivated it: thesame op applies cleanly whether or not the base ships the path.
Idempotency
TestPutFileIsDeterministicasserts the same ops over the same base givebyte-identical output, and that applying
PutFiletwice equals applying it once.It runs over both branches, which matter separately: replace rewrites an entry
in place, while create appends to the added list, so a repeat that appended twice
would emit the path twice rather than once.
This matters because the matrix shares one base across every parallel subtest: a
PutFilewhose second application differed from its first would make a fixture'scontent depend on what else ran.
An earlier draft of this PR also asserted that
Applyleaves the base bytesuntouched, and billed that as the load-bearing guarantee. That was wrong, and it
is worth stating rather than quietly dropping:
Applytakes anio.Readerandonly ever copies out of it, so it cannot write to a caller's slice through that
signature at all. The assertion could not fail whatever
Applydid internally —it really tested
buildTar's determinism.TestApplyReusesABaseSafelyassertsthe properties that genuinely can fail instead (no per-run state shared between
calls; ops not retaining and then mutating the caller's content slice) and
records that the remaining guard,
cp := *hdr, is structural and not observablefrom outside a single
Applycall.One stale premise found along the way
detect_openssl/fail_clean_no_fipshas no ops — it is the "an unmodified imagefails this rule" control. Its assertion is still correct, but its comment claimed
the vanilla base lacks the OpenSSL FIPS module config, and the base now ships
openssl.cnf. What is actually true of the new base:etc/ssl/openssl.cnfetc/ssl/fipsmodule.cnfSo it still fails, on the module config and the packages;
openssl.cnf's absenceis simply no longer part of why. Comment corrected, and the fixture deliberately
left op-free: it degrades loudly, not silently — if the base ever gained the
FIPS pieces, that row would flip to pass and fail the suite rather than quietly
assert less. Worth noting
AddFilenever protected that fixture either, since itcalls no ops, so this exposure was independent of the change.
Added in review
b9e6db2— a duplicate tar member on remove-then-re-add.RemoveFiledroppeda path from the entry map but left its name in the base ordering, so a later op
re-adding it made
Applywrite the path twice: once from the base-order loop,again from the added loop. The result is a fixture tar with a duplicated member
that no op reports as an error. Pre-existing and not reachable today — the sole
RemoveFilecall site never re-adds — butPutFile's "whether or not it alreadyexists" contract is what makes the pairing something an author would reach for, so
it is fixed here rather than documented as a footgun.
Same commit pins three
PutFileedges: a non-regular target fails withErrNotRegular(a real risk for these paths, not a theoretical one — the baseships
etc/ssl/cert.pemandetc/ssl/certs/ca-bundle.crtas symlinks toca-certificates.crt); idempotency covers the create branch; andsafePathischecked in
PutFileitself, making a guarantee that was emergent from two otherfunctions local.
doc.go's op list had drifted to four of seven ops.25c9bfb— base composition is now asserted directly. Converting all 29 callsto
PutFileremoves the base-drift alarm from the whole matrix at once: theErrExiststhat fired when the base gainedopenssl.cnfwas crude, but it wasthe only thing that noticed, and afterwards the failure mode inverts from "CI
breaks confusingly" to "a fixture quietly asserts less than its name claims".
TestBaseImageCompositioncovers that surface where the cause is, rather than asa downstream verdict flip: paths an existence-precondition op needs; paths whose
absence gives a fixture its discriminating power (the FIPS ssh drop-ins,
fipsmodule.cnf, the Java truststore pair behindCertificateAudittst:5'snone_exist);PutFiletargets being absent or regular files; and no FIPS apkpackages installed. Each row carries why it matters, so a failure explains itself.
etc/ssl/openssl.cnfis asserted only as "absent or regular" — it is the paththat actually drifted, so pinning it either way would re-create the bet
PutFileremoved. Re-pinned to #173's digest the check stays green; planting a wrong row in
any of the three path lists turns the matching subtest red naming the path, its
tar type and the dependent fixture. It needs only a registry pull, so it runs in
more environments than the matrix it protects.
This also gives the
fail_clean_no_fipspremise above a real home: both halves ofthat control — no
fipsmodule.cnf, no FIPS packages — are now checked explicitlyrather than resting on a comment.
🤖 Generated with Claude Code