One defect seen twice: script/lint decided whether it could skip the
pinned image by asking what version was on PATH rather than where it
was running, and cache isolation is part of that same question.
What changed
Per-worktree cache (#99.1).${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<slug>-<12-hex digest of $ROOT>,
still persistent, so a warm run stays seconds. The old single
per-repo directory is what let two checkouts with identical Go
contents collide and replay each other's stored analysis.
Foreign-path audit (#99.2) — new script/lint-audit, run on
every lint. Rejects output citing any file not in the tree being
linted: absolute paths outside the root, .. components, and
relative paths that do not exist here. Runs on clean output too,
because the unearned-pass direction is the silent one. It never
certifies that a run passed — it does not look at whether there were
findings — so it cannot itself become a gate reporting a green.
Split out as its own script so it is directly exercisable against a
saved capture, which is how it was verified.
Lock error is a retry, not a verdict (#99.3). Per #88 a private
cache does not remove the contention. parallel golangci-lint is running is retried up to 6 times, 15s apart; exhausting them fails
saying explicitly that the tree was never analysed.
Bounded growth (#99.4). Each cache records its worktree and is
collected when that worktree disappears, so throwaway worktrees do
not accumulate caches. The tree sits under XDG_CACHE_HOME and is
disposable; the removal command is in the script's comment and in the
audit's rejection message.
Context gate (#80.1). The native path now requires VAULTIK_LINT_IN_CONTAINER=1, set only by the Dockerfile lint
stage, and a matching version. /.dockerenv was rejected as the
signal: dockerd creates it for docker run, but it is not reliably
present during a BuildKit docker build, which is exactly the case
the exception exists for. Inside the container a version mismatch is
now a hard error rather than a fall-through — there is no daemon
there to fall through to.
Bootstrap (#80.2). Docker missing or its daemon unreachable is
now a hard failure naming script/lint, script/check, script/precommit and script/cibuild, instead of a warning
followed by bootstrap complete. Installing docker from bootstrap
was rejected: root, a daemon, and on macOS a GUI cask, so the attempt
would itself fail in the common case and trade one false success for
a second failure mode.
version --short (#80.3) replaces the banner scrape, which
survives only as a fallback.
#80.4/#80.5: TODO.md's "as trustworthy as script/cibuild"
corrected to match README.md (only the lint leg is equivalent); README.md requirements gained docker and sqlite3.
Verification (reproduction, not inspection)
Two concurrent lints, two worktrees, differing cleanliness — /tmp/impl-99 (clean) and /tmp/impl-99-dirty (one file with a revive and an unused finding), started together. Clean: 0 issues., exit 0. Dirty: exactly 2 findings, both citing internal/blobgen/lintbait.go in its own tree, exit 2. Neither log
contained parallel golangci-lint is running. No cross-contamination
in either direction.
Foreign-path guard, end to end — a real script/lint run in the
dirty worktree made to report outside paths reproduced the #99
signature (../impl-91/internal/blobgen/lintbait.go) and exited 1
with the REJECTED diagnostic, instead of reporting the findings. The
auditor was also run directly against the verbatim output from #99
(exit 1), a foreign path without a .. component (exit 1), a real
clean capture (exit 0), and a container-absolute /src/... path
(exit 0).
Context gate — with a golangci-lint shim on PATH reporting 2.12.2 and logging every invocation, make lint never invoked it
and ran the pinned image. With VAULTIK_LINT_IN_CONTAINER=1 the shim
was invoked (version --short, then run ./...), confirming that
variable is the only door. A mismatched version with that variable set
exits 1 naming both versions.
Bootstrap — PATH without docker: bootstrap: FAILED - docker is not installed., exit 1, zero occurrences of bootstrap complete. A docker shim whose info fails: bootstrap: FAILED - the docker daemon is not reachable., exit 1. Unchanged happy path on this host:
exit 0, bootstrap complete.
In-container path — script/cibuild exit 0, 3m24s wall. Cache
control: no prune, no --no-cache; the ENV added to the lint stage
invalidates everything below it, and the 11 CACHED layers are all
dependency/module layers. [lint 9/9] ... make lint executed for
78.7s inside the pinned image and printed 0 issues., which is what
proves the native path still works with no daemon; [builder 9/10] ... make test executed with all 14 ok lines and no (cached).
make check — exit 0, 18.6s: 14 ok lines, zero (cached), 0 issues., fmt-check clean.
Warm lint timing — before 2.9s, after 4.3s (cold 1m44s → 1m24s).
The added ~1.4s is the tee capture plus the audit pass over the
output; the inner loop is still seconds.
.golangci.yml unchanged, sha256 verified before push: 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.
The lint-stage FROM line and digest, the ARG CHECK_EPOCH guard
structure, REPO_POLICIES.md and script/test are untouched.
Known limitation, deliberately not papered over
script/lint bind-mounts the tree at /src, so two containerized runs
of different checkouts both call themselves /src. Contamination
between two container runs is therefore not distinguishable by path
alone, and the audit cannot catch it — which is why the per-worktree
cache key, not the audit, is the primary fix. The audit catches the
signature actually observed in #99 and any host-side path leakage. This
is documented at the top of script/lint-audit.
--allow-parallel-runners was considered instead of the retry and
rejected: it disables the lock that protects a cache shared by two runs
of the same worktree, which is a real configuration, and it would trade
a delay for corruption.
Closes #99. Closes #80.
One defect seen twice: `script/lint` decided whether it could skip the
pinned image by asking what version was on `PATH` rather than where it
was running, and cache isolation is part of that same question.
## What changed
1. **Per-worktree cache (#99.1).** `${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<slug>-<12-hex digest of $ROOT>`,
still persistent, so a warm run stays seconds. The old single
per-repo directory is what let two checkouts with identical Go
contents collide and replay each other's stored analysis.
2. **Foreign-path audit (#99.2)** — new `script/lint-audit`, run on
every lint. Rejects output citing any file not in the tree being
linted: absolute paths outside the root, `..` components, and
relative paths that do not exist here. Runs on clean output too,
because the unearned-**pass** direction is the silent one. It never
certifies that a run passed — it does not look at whether there were
findings — so it cannot itself become a gate reporting a green.
Split out as its own script so it is directly exercisable against a
saved capture, which is how it was verified.
3. **Lock error is a retry, not a verdict (#99.3).** Per #88 a private
cache does not remove the contention. `parallel golangci-lint is
running` is retried up to 6 times, 15s apart; exhausting them fails
saying explicitly that the tree was never analysed.
4. **Bounded growth (#99.4).** Each cache records its worktree and is
collected when that worktree disappears, so throwaway worktrees do
not accumulate caches. The tree sits under `XDG_CACHE_HOME` and is
disposable; the removal command is in the script's comment and in the
audit's rejection message.
5. **Context gate (#80.1).** The native path now requires
`VAULTIK_LINT_IN_CONTAINER=1`, set only by the `Dockerfile` lint
stage, **and** a matching version. `/.dockerenv` was rejected as the
signal: `dockerd` creates it for `docker run`, but it is not reliably
present during a BuildKit `docker build`, which is exactly the case
the exception exists for. Inside the container a version mismatch is
now a hard error rather than a fall-through — there is no daemon
there to fall through to.
6. **Bootstrap (#80.2).** Docker missing or its daemon unreachable is
now a hard failure naming `script/lint`, `script/check`,
`script/precommit` and `script/cibuild`, instead of a warning
followed by `bootstrap complete`. Installing docker from bootstrap
was rejected: root, a daemon, and on macOS a GUI cask, so the attempt
would itself fail in the common case and trade one false success for
a second failure mode.
7. **`version --short` (#80.3)** replaces the banner scrape, which
survives only as a fallback.
8. **#80.4/#80.5**: `TODO.md`'s "as trustworthy as `script/cibuild`"
corrected to match `README.md` (only the lint leg is equivalent);
`README.md` requirements gained docker and `sqlite3`.
## Verification (reproduction, not inspection)
* **Two concurrent lints, two worktrees, differing cleanliness** —
`/tmp/impl-99` (clean) and `/tmp/impl-99-dirty` (one file with a
`revive` and an `unused` finding), started together. Clean: `0
issues.`, exit 0. Dirty: exactly 2 findings, both citing
`internal/blobgen/lintbait.go` in its own tree, exit 2. Neither log
contained `parallel golangci-lint is running`. No cross-contamination
in either direction.
* **Foreign-path guard, end to end** — a real `script/lint` run in the
dirty worktree made to report outside paths reproduced the #99
signature (`../impl-91/internal/blobgen/lintbait.go`) and exited **1**
with the REJECTED diagnostic, instead of reporting the findings. The
auditor was also run directly against the verbatim output from #99
(exit 1), a foreign path without a `..` component (exit 1), a real
clean capture (exit 0), and a container-absolute `/src/...` path
(exit 0).
* **Context gate** — with a `golangci-lint` shim on `PATH` reporting
`2.12.2` and logging every invocation, `make lint` never invoked it
and ran the pinned image. With `VAULTIK_LINT_IN_CONTAINER=1` the shim
was invoked (`version --short`, then `run ./...`), confirming that
variable is the only door. A mismatched version with that variable set
exits 1 naming both versions.
* **Bootstrap** — `PATH` without docker: `bootstrap: FAILED - docker is
not installed.`, exit 1, zero occurrences of `bootstrap complete`. A
`docker` shim whose `info` fails: `bootstrap: FAILED - the docker
daemon is not reachable.`, exit 1. Unchanged happy path on this host:
exit 0, `bootstrap complete`.
* **In-container path** — `script/cibuild` exit 0, 3m24s wall. Cache
control: no prune, no `--no-cache`; the `ENV` added to the lint stage
invalidates everything below it, and the 11 `CACHED` layers are all
dependency/module layers. `[lint 9/9] ... make lint` executed for
78.7s inside the pinned image and printed `0 issues.`, which is what
proves the native path still works with no daemon; `[builder 9/10] ...
make test` executed with all 14 `ok` lines and no `(cached)`.
* **`make check`** — exit 0, 18.6s: 14 `ok` lines, zero `(cached)`,
`0 issues.`, `fmt-check` clean.
* **Warm lint timing** — before 2.9s, after 4.3s (cold 1m44s → 1m24s).
The added ~1.4s is the `tee` capture plus the audit pass over the
output; the inner loop is still seconds.
* `.golangci.yml` unchanged, sha256 verified before push:
`021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`.
The lint-stage `FROM` line and digest, the `ARG CHECK_EPOCH` guard
structure, `REPO_POLICIES.md` and `script/test` are untouched.
## Known limitation, deliberately not papered over
`script/lint` bind-mounts the tree at `/src`, so two containerized runs
of different checkouts both call themselves `/src`. Contamination
between two container runs is therefore not distinguishable by path
alone, and the audit cannot catch it — which is why the per-worktree
cache key, not the audit, is the primary fix. The audit catches the
signature actually observed in #99 and any host-side path leakage. This
is documented at the top of `script/lint-audit`.
`--allow-parallel-runners` was considered instead of the retry and
rejected: it disables the lock that protects a cache shared by two runs
of the same worktree, which is a real configuration, and it would trade
a delay for corruption.
Closes#80.
script/lint decided whether it could skip the pinned image by asking what
version was on PATH rather than where it was running, and cache isolation
is part of that same question. Both issues are that one defect.
The cache was one directory per repo, shared by every worktree on the
host. Two checkouts of this repo have identical Go file contents, so
their cache keys collide and golangci-lint replays the stored analysis,
including the paths recorded when it was produced. The loud direction of
that failure - a clean tree failed by a dirty sibling - is the harmless
one. The silent direction, a dirty tree passed by a clean sibling, is
another way for a gate here to report a green it did not earn.
The cache is now keyed on a digest of the worktree path, so a collision
is not possible, and it stays persistent per worktree: a warm run is
still seconds. Each cache records the worktree it belongs to and is
collected when that worktree is gone, so throwaway worktrees do not
accumulate caches; the tree lives under XDG_CACHE_HOME and is disposable
by definition.
script/lint-audit is the backstop, and runs on every lint: it rejects
output citing any file that is not in the tree being linted, so a result
built out of another checkout's analysis is a hard error instead of a
silent pass. It runs on clean output too, because that is the case
nobody investigates. It never certifies that a run passed - it does not
look at whether there were findings - so it cannot itself become a gate
that reports a green.
golangci-lint's "parallel golangci-lint is running" refusal is now a
bounded retry rather than a verdict. It is not a lint result, and
exiting non-zero on it is indistinguishable to a caller from real
findings; issue #88 measured that a private cache does not remove the
contention. Exhausting the retries fails with a message that says the
tree was never analysed.
The native path now requires VAULTIK_LINT_IN_CONTAINER=1, which only the
Dockerfile's lint stage sets, in addition to matching the pin. A
developer's locally installed 2.12.2 is a different build reached by a
different code path and no longer bypasses the digest pin. /.dockerenv
was considered and rejected as the signal: dockerd creates it for
`docker run`, but it is not reliably present during a BuildKit
`docker build`, which is exactly the case the exception exists for.
Inside the container a version mismatch is now a hard error rather than
a fall-through, since there is no daemon there to fall through to.
Version detection uses `golangci-lint version --short`, the interface
meant for it, keeping the banner scrape only as a fallback.
script/bootstrap no longer prints "bootstrap complete" on a machine that
cannot run the gate. Docker missing, or present with an unreachable
daemon, is a hard failure naming exactly what breaks. Installing docker
from bootstrap was rejected: it needs root, a daemon, and on macOS a GUI
cask, so the attempt would itself fail in the common case and trade one
false success for a second failure mode.
TODO.md's claim that `make check` became "as trustworthy as
script/cibuild" is corrected to what README.md already said: only the
lint leg is equivalent, while tests and gofmt still run against the host
toolchain. README.md's requirements section gains docker and sqlite3.
Verified by reproduction, not inspection: two concurrent lints from two
worktrees of differing cleanliness each reported only their own findings
with no lock error; a real run made to report paths outside its tree
exits 1; a matching linter shimmed onto PATH is never invoked while the
pinned image runs; a PATH without docker makes bootstrap fail. script/
cibuild exits 0 with the lint layer executing in the pinned image, which
is what proves the in-container path still works.
Addendum to the verification above, because the cache-collection item
(#99.4) failed its own negative control before it passed one, and the
failure is worth recording rather than quietly fixing.
The first cut collected a stale cache with a plain rm -rf. Go's module
cache inside it is deliberately read-only, so rm could not unlink
files out of directories it may not write; it exited non-zero, set -e
took the whole script down, and make lint returned an error on a tree
with zero findings — a gate failing for a housekeeping reason, which is
its own bug and exactly the class this repo keeps paying for. It also
left a half-removed cache with no marker file, which every future run
would have skipped forever.
Fixed: the tree is made writable before removal, a removal that still
fails prints a warning and restores the marker so the cache stays
collectable, and no tidy-up failure can fail a lint.
Re-verified against a stale cache containing a read-only
module-cache-like tree with its marker pointing at a removed worktree:
the cache was collected and the lint exited 0 with 0 issues.. make check re-run: exit 0, 16.2s, 14 ok lines, zero (cached), 0 issues.. script/cibuild re-run: exit 0, 2m43s, [lint 9/9]
executed for 53.3s in the pinned image printing 0 issues., no --no-cache and no prune used. .golangci.yml sha256 re-verified
before the force-push: 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.
The branch was force-updated to keep this as one commit (426a8d7 to 043aabb).
Addendum to the verification above, because the cache-collection item
(#99.4) failed its own negative control before it passed one, and the
failure is worth recording rather than quietly fixing.
The first cut collected a stale cache with a plain `rm -rf`. Go's module
cache inside it is deliberately read-only, so `rm` could not unlink
files out of directories it may not write; it exited non-zero, `set -e`
took the whole script down, and `make lint` returned an error on a tree
with zero findings — a gate failing for a housekeeping reason, which is
its own bug and exactly the class this repo keeps paying for. It also
left a half-removed cache with no marker file, which every future run
would have skipped forever.
Fixed: the tree is made writable before removal, a removal that still
fails prints a warning and restores the marker so the cache stays
collectable, and no tidy-up failure can fail a lint.
Re-verified against a stale cache containing a read-only
module-cache-like tree with its marker pointing at a removed worktree:
the cache was collected and the lint exited 0 with `0 issues.`.
`make check` re-run: exit 0, 16.2s, 14 `ok` lines, zero `(cached)`,
`0 issues.`. `script/cibuild` re-run: exit 0, 2m43s, `[lint 9/9]`
executed for 53.3s in the pinned image printing `0 issues.`, no
`--no-cache` and no prune used. `.golangci.yml` sha256 re-verified
before the force-push: `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`.
The branch was force-updated to keep this as one commit (`426a8d7` to
`043aabb`).
Independent review of #102 against #99 and #80. Verified by reproduction on this
host, not by inspection. Nothing found that can produce an unearned green; two
fail-closed defects and one accuracy nit below.
Probes run (each reproduced, not assumed)
The two-worktree reproduction. Two worktrees of this branch, one clean and
one carrying a planted unused + nlreturn bait, make lint started
concurrently under one shared XDG_CACHE_HOME: two distinct cache directories
(...-9fdd462e03dc, ...-c679b0c15419), clean exit 0 / 0 issues., dirty
exit 1 with exactly 2 findings both citing its own internal/blobgen/rv102bait.go, zero occurrences of the lock message, zero
foreign paths in either direction. Issue #99 does not reproduce on this branch.
script/lint-audit, 23 vectors. Rejects the verbatim #99 signature,
absolute-foreign with no .., mid-path ../, whitespace- and tab-prefixed
foreign paths, and /src/- or $ROOT/-prefixed paths naming files not present
here. Accepts /src/-, $ROOT/- and relative-prefixed paths that do exist,
clean output, empty output. Crucially it also exits 0 on a capture containing
three real findings, so the "never certifies a pass" design genuinely holds and
it cannot become a second gate reporting green. Missing/extra arguments exit 2
and audit_output treats any non-zero as reject, so the auditor fails closed.
Context gate. With a logging golangci-lint shim reporting 2.12.2 first
on PATH, make lint never invoked it (the shim's log file was never created)
and ran the pinned image. With VAULTIK_LINT_IN_CONTAINER=1 the shim was
invoked (version --short, then run ./...). Var set plus shim reporting 2.10.1: exit 1 naming both versions. Var set to true rather than 1: docker
path. The variable is the only door. Rejecting /.dockerenv is sound - it is a dockerd runtime artifact, absent during a BuildKit docker build, which is
the only case the exception serves.
Worth recording: this host has golangci-lint 2.12.2 on PATH, so on mainscript/lintexecs the host binary against the global default ~/.cache/golangci-lint and never runs the pinned image at all. That is very
likely the actual mechanism behind the ../impl-91/ paths in #99 - host, cwd-relative rendering of
another worktree's absolute paths - and this PR closes it at the root, so the
fix is stronger than the issue's own diagnosis.
Lock retry. Shim emitting the refusal on every attempt: 6 attempts, 75s,
exit 1 with the explicit "NOT a lint verdict: the tree was never analysed"
text. Non-zero and self-describing; not mistakable for a lint result.
script/cibuild: exit 0, 185s wall, no prune and no --no-cache. 14 CACHED layers, all dependency/setup; the check layers executed - #16 make fmt-check, #17 make lint DONE 52.8s printing 0 issues. inside the pinned
image with no daemon, #25 make test DONE 89.6s with 14 ok lines and 0 (cached). Fresh CHECK_EPOCH in both stages. Disclosure: my first attempt
exited 127 because /usr/bin/time is absent here; that run proved nothing and
was discarded, the figures above are the re-run.
Nothing weakened:.golangci.yml sha256 021cc83f4e6f... matches; REPO_POLICIES.md, script/test, script/check, script/cibuild, script/precommit, script/fmt, Makefile and .gitea/ byte-identical to main; lint-stage FROM + digest and both ARG CHECK_EPOCH guards intact; no
Go file touched; ENV VAULTIK_LINT_IN_CONTAINER=1 is confined to the lint
stage and inherited by neither builder nor the runtime image.
CI green on 043aabb (3m11s); mergeable, merges cleanly against main at c51f693; make fmt a no-op; commit title ends (closes #99) and the body closes#80; no attribution trailers or vendor references anywhere; inclusive
terminology, naming and idiom consistent with the surrounding scripts; no scope
creep.
Non-blocking findings
1. script/lint:174 - a tidy-up failure can still fail a lint, contradicting
the commit message.
owner="$(cat "$dir/worktree")"
This is the one unguarded command in prune_dead_caches; every other operation
in the collection path is || true or if !-guarded. Under set -eu an
assignment whose command substitution fails aborts the script. The commit
message states the opposite ("never lets a failure to tidy up fail the lint"),
and this is the same class as the self-reported rm -rf anomaly.
Demonstrated: with a stale cache whose marker file is unreadable, ./script/lint exits 1 having printed only cat: .../worktree: Permission denied, never reaching the linter. The realistic
trigger is two concurrent lints collecting the same stale cache - [ -f "$dir/worktree" ] true for B, then A's rm -rf unlinks the marker before B's cat. I could not win that race in 36 concurrent runs, so the window is narrow,
and the failure direction is closed (a loud lint error, never a false green) -
which is why this is not blocking.
Acceptable: owner="$(cat "$dir/worktree" 2>/dev/null || true)". The existing [ -z "$owner" ] check already handles the empty result.
2. script/lint-audit:63-66 - a genuine dependency error is reported as cache
contamination.
Any absolute path not under $ROOT or /src is classed foreign. Inside the
container GOMODCACHE=/cache/go-mod, so a typecheck finding citing a
dependency source file is rejected: verified, /cache/go-mod/example.com/x@v1.0.0/a.go:3:1: msg (typecheck) exits 1 with "the
linter reported findings for files that are not in this tree ... clear this
tree's lint cache". That is the wrong diagnosis for a real dependency
compile error, and it sends the reader to clear a cache that is not the cause.
Fails closed, so not blocking.
Acceptable: exempt /cache/go-mod/ and /cache/go-build/ (the paths script/lint itself sets) from the foreign set, or soften the message when every
foreign path lies under the cache mount.
Nit
The PR body and the #99 comment report
the dirty worktree exiting 2. Observed here: exit 1 with 2 findings,
which is golangci-lint's default issues-exit-code. Immaterial to the outcome,
but a verification narrative in this repo should match what the tool actually
did.
## Review: PASS
Independent review of https://git.eeqj.de/sneak/vaultik/pulls/102 against
https://git.eeqj.de/sneak/vaultik/issues/99 and
https://git.eeqj.de/sneak/vaultik/issues/80. Verified by reproduction on this
host, not by inspection. Nothing found that can produce an unearned green; two
fail-closed defects and one accuracy nit below.
### Probes run (each reproduced, not assumed)
- **The two-worktree reproduction.** Two worktrees of this branch, one clean and
one carrying a planted `unused` + `nlreturn` bait, `make lint` started
concurrently under one shared `XDG_CACHE_HOME`: two distinct cache directories
(`...-9fdd462e03dc`, `...-c679b0c15419`), clean exit 0 / `0 issues.`, dirty
exit 1 with exactly 2 findings both citing its own
`internal/blobgen/rv102bait.go`, zero occurrences of the lock message, zero
foreign paths in either direction. Issue
https://git.eeqj.de/sneak/vaultik/issues/99 does not reproduce on this branch.
- **`script/lint-audit`, 23 vectors.** Rejects the verbatim #99 signature,
absolute-foreign with no `..`, mid-path `../`, whitespace- and tab-prefixed
foreign paths, and `/src/`- or `$ROOT/`-prefixed paths naming files not present
here. Accepts `/src/`-, `$ROOT/`- and relative-prefixed paths that do exist,
clean output, empty output. Crucially it also exits 0 on a capture containing
three real findings, so the "never certifies a pass" design genuinely holds and
it cannot become a second gate reporting green. Missing/extra arguments exit 2
and `audit_output` treats any non-zero as reject, so the auditor fails closed.
- **Context gate.** With a logging `golangci-lint` shim reporting `2.12.2` first
on `PATH`, `make lint` never invoked it (the shim's log file was never created)
and ran the pinned image. With `VAULTIK_LINT_IN_CONTAINER=1` the shim was
invoked (`version --short`, then `run ./...`). Var set plus shim reporting
`2.10.1`: exit 1 naming both versions. Var set to `true` rather than `1`: docker
path. The variable is the only door. Rejecting `/.dockerenv` is sound - it is a
`dockerd` runtime artifact, absent during a BuildKit `docker build`, which is
the only case the exception serves.
- **Worth recording:** this host has `golangci-lint` 2.12.2 on `PATH`, so on
`main` `script/lint` `exec`s the host binary against the *global default*
`~/.cache/golangci-lint` and never runs the pinned image at all. That is very
likely the actual mechanism behind the `../impl-91/` paths in
https://git.eeqj.de/sneak/vaultik/issues/99 - host, cwd-relative rendering of
another worktree's absolute paths - and this PR closes it at the root, so the
fix is stronger than the issue's own diagnosis.
- **Lock retry.** Shim emitting the refusal on every attempt: 6 attempts, 75s,
exit 1 with the explicit "NOT a lint verdict: the tree was never analysed"
text. Non-zero and self-describing; not mistakable for a lint result.
- **`script/cibuild`:** exit 0, 185s wall, no prune and no `--no-cache`. 14
`CACHED` layers, all dependency/setup; the check layers executed - `#16 make
fmt-check`, `#17 make lint` DONE 52.8s printing `0 issues.` inside the pinned
image with no daemon, `#25 make test` DONE 89.6s with 14 `ok` lines and **0**
`(cached)`. Fresh `CHECK_EPOCH` in both stages. Disclosure: my first attempt
exited 127 because `/usr/bin/time` is absent here; that run proved nothing and
was discarded, the figures above are the re-run.
- **Nothing weakened:** `.golangci.yml` sha256 `021cc83f4e6f...` matches;
`REPO_POLICIES.md`, `script/test`, `script/check`, `script/cibuild`,
`script/precommit`, `script/fmt`, `Makefile` and `.gitea/` byte-identical to
`main`; lint-stage `FROM` + digest and both `ARG CHECK_EPOCH` guards intact; no
Go file touched; `ENV VAULTIK_LINT_IN_CONTAINER=1` is confined to the `lint`
stage and inherited by neither `builder` nor the runtime image.
- CI green on `043aabb` (3m11s); mergeable, merges cleanly against `main` at
`c51f693`; `make fmt` a no-op; commit title ends ` (closes #99)` and the body
closes #80; no attribution trailers or vendor references anywhere; inclusive
terminology, naming and idiom consistent with the surrounding scripts; no scope
creep.
### Non-blocking findings
**1. `script/lint:174` - a tidy-up failure can still fail a lint, contradicting
the commit message.**
```sh
owner="$(cat "$dir/worktree")"
```
This is the one unguarded command in `prune_dead_caches`; every other operation
in the collection path is `|| true` or `if !`-guarded. Under `set -eu` an
assignment whose command substitution fails aborts the script. The commit
message states the opposite ("never lets a failure to tidy up fail the lint"),
and this is the same class as the self-reported `rm -rf` anomaly.
Demonstrated: with a stale cache whose marker file is unreadable,
`./script/lint` exits 1 having printed only
`cat: .../worktree: Permission denied`, never reaching the linter. The realistic
trigger is two concurrent lints collecting the same stale cache - `[ -f
"$dir/worktree" ]` true for B, then A's `rm -rf` unlinks the marker before B's
`cat`. I could not win that race in 36 concurrent runs, so the window is narrow,
and the failure direction is closed (a loud lint error, never a false green) -
which is why this is not blocking.
Acceptable: `owner="$(cat "$dir/worktree" 2>/dev/null || true)"`. The existing
`[ -z "$owner" ]` check already handles the empty result.
**2. `script/lint-audit:63-66` - a genuine dependency error is reported as cache
contamination.**
Any absolute path not under `$ROOT` or `/src` is classed foreign. Inside the
container `GOMODCACHE=/cache/go-mod`, so a `typecheck` finding citing a
dependency source file is rejected: verified,
`/cache/go-mod/example.com/x@v1.0.0/a.go:3:1: msg (typecheck)` exits 1 with "the
linter reported findings for files that are not in this tree ... clear this
tree's lint cache". That is the wrong diagnosis for a real dependency
compile error, and it sends the reader to clear a cache that is not the cause.
Fails closed, so not blocking.
Acceptable: exempt `/cache/go-mod/` and `/cache/go-build/` (the paths
`script/lint` itself sets) from the foreign set, or soften the message when every
foreign path lies under the cache mount.
### Nit
- The PR body and the https://git.eeqj.de/sneak/vaultik/issues/99 comment report
the dirty worktree exiting **2**. Observed here: exit **1** with 2 findings,
which is golangci-lint's default `issues-exit-code`. Immaterial to the outcome,
but a verification narrative in this repo should match what the tool actually
did.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #99. Closes #80.
One defect seen twice:
script/lintdecided whether it could skip thepinned image by asking what version was on
PATHrather than where itwas running, and cache isolation is part of that same question.
What changed
${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<slug>-<12-hex digest of $ROOT>,still persistent, so a warm run stays seconds. The old single
per-repo directory is what let two checkouts with identical Go
contents collide and replay each other's stored analysis.
script/lint-audit, run onevery lint. Rejects output citing any file not in the tree being
linted: absolute paths outside the root,
..components, andrelative paths that do not exist here. Runs on clean output too,
because the unearned-pass direction is the silent one. It never
certifies that a run passed — it does not look at whether there were
findings — so it cannot itself become a gate reporting a green.
Split out as its own script so it is directly exercisable against a
saved capture, which is how it was verified.
cache does not remove the contention.
parallel golangci-lint is runningis retried up to 6 times, 15s apart; exhausting them failssaying explicitly that the tree was never analysed.
collected when that worktree disappears, so throwaway worktrees do
not accumulate caches. The tree sits under
XDG_CACHE_HOMEand isdisposable; the removal command is in the script's comment and in the
audit's rejection message.
VAULTIK_LINT_IN_CONTAINER=1, set only by theDockerfilelintstage, and a matching version.
/.dockerenvwas rejected as thesignal:
dockerdcreates it fordocker run, but it is not reliablypresent during a BuildKit
docker build, which is exactly the casethe exception exists for. Inside the container a version mismatch is
now a hard error rather than a fall-through — there is no daemon
there to fall through to.
now a hard failure naming
script/lint,script/check,script/precommitandscript/cibuild, instead of a warningfollowed by
bootstrap complete. Installing docker from bootstrapwas rejected: root, a daemon, and on macOS a GUI cask, so the attempt
would itself fail in the common case and trade one false success for
a second failure mode.
version --short(#80.3) replaces the banner scrape, whichsurvives only as a fallback.
TODO.md's "as trustworthy asscript/cibuild"corrected to match
README.md(only the lint leg is equivalent);README.mdrequirements gained docker andsqlite3.Verification (reproduction, not inspection)
/tmp/impl-99(clean) and/tmp/impl-99-dirty(one file with areviveand anunusedfinding), started together. Clean:0 issues., exit 0. Dirty: exactly 2 findings, both citinginternal/blobgen/lintbait.goin its own tree, exit 2. Neither logcontained
parallel golangci-lint is running. No cross-contaminationin either direction.
script/lintrun in thedirty worktree made to report outside paths reproduced the #99
signature (
../impl-91/internal/blobgen/lintbait.go) and exited 1with the REJECTED diagnostic, instead of reporting the findings. The
auditor was also run directly against the verbatim output from #99
(exit 1), a foreign path without a
..component (exit 1), a realclean capture (exit 0), and a container-absolute
/src/...path(exit 0).
golangci-lintshim onPATHreporting2.12.2and logging every invocation,make lintnever invoked itand ran the pinned image. With
VAULTIK_LINT_IN_CONTAINER=1the shimwas invoked (
version --short, thenrun ./...), confirming thatvariable is the only door. A mismatched version with that variable set
exits 1 naming both versions.
PATHwithout docker:bootstrap: FAILED - docker is not installed., exit 1, zero occurrences ofbootstrap complete. Adockershim whoseinfofails:bootstrap: FAILED - the docker daemon is not reachable., exit 1. Unchanged happy path on this host:exit 0,
bootstrap complete.script/cibuildexit 0, 3m24s wall. Cachecontrol: no prune, no
--no-cache; theENVadded to the lint stageinvalidates everything below it, and the 11
CACHEDlayers are alldependency/module layers.
[lint 9/9] ... make lintexecuted for78.7s inside the pinned image and printed
0 issues., which is whatproves the native path still works with no daemon;
[builder 9/10] ... make testexecuted with all 14oklines and no(cached).make check— exit 0, 18.6s: 14oklines, zero(cached),0 issues.,fmt-checkclean.The added ~1.4s is the
teecapture plus the audit pass over theoutput; the inner loop is still seconds.
.golangci.ymlunchanged, sha256 verified before push:021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.The lint-stage
FROMline and digest, theARG CHECK_EPOCHguardstructure,
REPO_POLICIES.mdandscript/testare untouched.Known limitation, deliberately not papered over
script/lintbind-mounts the tree at/src, so two containerized runsof different checkouts both call themselves
/src. Contaminationbetween two container runs is therefore not distinguishable by path
alone, and the audit cannot catch it — which is why the per-worktree
cache key, not the audit, is the primary fix. The audit catches the
signature actually observed in #99 and any host-side path leakage. This
is documented at the top of
script/lint-audit.--allow-parallel-runnerswas considered instead of the retry andrejected: it disables the lock that protects a cache shared by two runs
of the same worktree, which is a real configuration, and it would trade
a delay for corruption.
426a8d7645to043aabbd27Addendum to the verification above, because the cache-collection item
(#99.4) failed its own negative control before it passed one, and the
failure is worth recording rather than quietly fixing.
The first cut collected a stale cache with a plain
rm -rf. Go's modulecache inside it is deliberately read-only, so
rmcould not unlinkfiles out of directories it may not write; it exited non-zero,
set -etook the whole script down, and
make lintreturned an error on a treewith zero findings — a gate failing for a housekeeping reason, which is
its own bug and exactly the class this repo keeps paying for. It also
left a half-removed cache with no marker file, which every future run
would have skipped forever.
Fixed: the tree is made writable before removal, a removal that still
fails prints a warning and restores the marker so the cache stays
collectable, and no tidy-up failure can fail a lint.
Re-verified against a stale cache containing a read-only
module-cache-like tree with its marker pointing at a removed worktree:
the cache was collected and the lint exited 0 with
0 issues..make checkre-run: exit 0, 16.2s, 14oklines, zero(cached),0 issues..script/cibuildre-run: exit 0, 2m43s,[lint 9/9]executed for 53.3s in the pinned image printing
0 issues., no--no-cacheand no prune used..golangci.ymlsha256 re-verifiedbefore the force-push:
021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.The branch was force-updated to keep this as one commit (
426a8d7to043aabb).Review: PASS
Independent review of #102 against
#99 and
#80. Verified by reproduction on this
host, not by inspection. Nothing found that can produce an unearned green; two
fail-closed defects and one accuracy nit below.
Probes run (each reproduced, not assumed)
one carrying a planted
unused+nlreturnbait,make lintstartedconcurrently under one shared
XDG_CACHE_HOME: two distinct cache directories(
...-9fdd462e03dc,...-c679b0c15419), clean exit 0 /0 issues., dirtyexit 1 with exactly 2 findings both citing its own
internal/blobgen/rv102bait.go, zero occurrences of the lock message, zeroforeign paths in either direction. Issue
#99 does not reproduce on this branch.
script/lint-audit, 23 vectors. Rejects the verbatim #99 signature,absolute-foreign with no
.., mid-path../, whitespace- and tab-prefixedforeign paths, and
/src/- or$ROOT/-prefixed paths naming files not presenthere. Accepts
/src/-,$ROOT/- and relative-prefixed paths that do exist,clean output, empty output. Crucially it also exits 0 on a capture containing
three real findings, so the "never certifies a pass" design genuinely holds and
it cannot become a second gate reporting green. Missing/extra arguments exit 2
and
audit_outputtreats any non-zero as reject, so the auditor fails closed.golangci-lintshim reporting2.12.2firston
PATH,make lintnever invoked it (the shim's log file was never created)and ran the pinned image. With
VAULTIK_LINT_IN_CONTAINER=1the shim wasinvoked (
version --short, thenrun ./...). Var set plus shim reporting2.10.1: exit 1 naming both versions. Var set totruerather than1: dockerpath. The variable is the only door. Rejecting
/.dockerenvis sound - it is adockerdruntime artifact, absent during a BuildKitdocker build, which isthe only case the exception serves.
golangci-lint2.12.2 onPATH, so onmainscript/lintexecs the host binary against the global default~/.cache/golangci-lintand never runs the pinned image at all. That is verylikely the actual mechanism behind the
../impl-91/paths in#99 - host, cwd-relative rendering of
another worktree's absolute paths - and this PR closes it at the root, so the
fix is stronger than the issue's own diagnosis.
exit 1 with the explicit "NOT a lint verdict: the tree was never analysed"
text. Non-zero and self-describing; not mistakable for a lint result.
script/cibuild: exit 0, 185s wall, no prune and no--no-cache. 14CACHEDlayers, all dependency/setup; the check layers executed -#16 make fmt-check,#17 make lintDONE 52.8s printing0 issues.inside the pinnedimage with no daemon,
#25 make testDONE 89.6s with 14oklines and 0(cached). FreshCHECK_EPOCHin both stages. Disclosure: my first attemptexited 127 because
/usr/bin/timeis absent here; that run proved nothing andwas discarded, the figures above are the re-run.
.golangci.ymlsha256021cc83f4e6f...matches;REPO_POLICIES.md,script/test,script/check,script/cibuild,script/precommit,script/fmt,Makefileand.gitea/byte-identical tomain; lint-stageFROM+ digest and bothARG CHECK_EPOCHguards intact; noGo file touched;
ENV VAULTIK_LINT_IN_CONTAINER=1is confined to thelintstage and inherited by neither
buildernor the runtime image.043aabb(3m11s); mergeable, merges cleanly againstmainatc51f693;make fmta no-op; commit title ends(closes #99)and the bodycloses #80; no attribution trailers or vendor references anywhere; inclusive
terminology, naming and idiom consistent with the surrounding scripts; no scope
creep.
Non-blocking findings
1.
script/lint:174- a tidy-up failure can still fail a lint, contradictingthe commit message.
This is the one unguarded command in
prune_dead_caches; every other operationin the collection path is
|| trueorif !-guarded. Underset -euanassignment whose command substitution fails aborts the script. The commit
message states the opposite ("never lets a failure to tidy up fail the lint"),
and this is the same class as the self-reported
rm -rfanomaly.Demonstrated: with a stale cache whose marker file is unreadable,
./script/lintexits 1 having printed onlycat: .../worktree: Permission denied, never reaching the linter. The realistictrigger is two concurrent lints collecting the same stale cache -
[ -f "$dir/worktree" ]true for B, then A'srm -rfunlinks the marker before B'scat. I could not win that race in 36 concurrent runs, so the window is narrow,and the failure direction is closed (a loud lint error, never a false green) -
which is why this is not blocking.
Acceptable:
owner="$(cat "$dir/worktree" 2>/dev/null || true)". The existing[ -z "$owner" ]check already handles the empty result.2.
script/lint-audit:63-66- a genuine dependency error is reported as cachecontamination.
Any absolute path not under
$ROOTor/srcis classed foreign. Inside thecontainer
GOMODCACHE=/cache/go-mod, so atypecheckfinding citing adependency source file is rejected: verified,
/cache/go-mod/example.com/x@v1.0.0/a.go:3:1: msg (typecheck)exits 1 with "thelinter reported findings for files that are not in this tree ... clear this
tree's lint cache". That is the wrong diagnosis for a real dependency
compile error, and it sends the reader to clear a cache that is not the cause.
Fails closed, so not blocking.
Acceptable: exempt
/cache/go-mod/and/cache/go-build/(the pathsscript/lintitself sets) from the foreign set, or soften the message when everyforeign path lies under the cache mount.
Nit
the dirty worktree exiting 2. Observed here: exit 1 with 2 findings,
which is golangci-lint's default
issues-exit-code. Immaterial to the outcome,but a verification narrative in this repo should match what the tool actually
did.