script/test omitted -count=1, so Go's test result cache could satisfy
the gate outright. A cached package prints `ok <pkg> (cached)`, and
that is an `ok` line: two back-to-back `make test` runs on the parent
commit produced the same 14 `ok` lines, the second in 0.42s with every
line marked (cached), having executed no test at all. The evidence
signal this repo leans on was therefore forgeable. This sits one level
below the Docker layer cache #85 addressed: CHECK_EPOCH forces the
`RUN make test` step to re-execute, but a GOCACHE baked into an earlier
image layer would survive into the re-executed step, so the step can
re-run and still do no work.
Add -count=1 unconditionally rather than only in the containerised
path. The pre-commit hook runs this same script, and a gate that is
honest only in CI is dishonest exactly where people rely on it most.
test-coverage gets the same flag -- a coverage profile assembled from
cached results describes a run that did not happen -- and script/check
inherits it by calling script/test.
Delete make test-integration rather than making it real (option (b) of
issue #69). No file in the repo carries a build tag, so -tags=integration
selected nothing extra and the target was an exact duplicate of make
test, while internal/vaultik/integration_test.go ran unconditionally on
every make test -- the opposite of what a reader of the Makefile would
conclude. Tagging a subset was rejected because the entire suite runs in
well under a minute, so gating would save seconds in exchange for a
build-tag scheme and a second CI path that must be kept wired up; in a
repo that has now found five distinct ways for a gate to report an
unearned green, a mechanism whose failure mode is "some tests silently
stopped running" is a bad trade. Deleting it also means nothing can
drop out of CI coverage, since nothing is conditional. The Makefile and
README now say plainly that make test runs everything.
Raise -timeout from 30s to 120s, after measuring rather than assuming.
The standing claim that cold-cache compilation is charged against
-timeout is false: -timeout reaches the test binary as -test.timeout
and its clock starts inside testing.M.Run, after compilation and
linking. A containerised run with an empty GOCACHE spent 46s compiling
and still reported per-package durations within noise of a warm host
run. The real exposure was margin, not compilation: against the slowest
package's worst observed time, 10.2s for internal/database on a cold
run, 30s left only 2.9x, thin for a loaded CI runner, and each fresh
measurement of that package has come in above the last. -timeout is a
hang backstop, not a performance budget, so it should sit far above the
slowest legitimate runtime; 120s leaves about 12x while still bounding
a hung package, including the verbose rerun, to a few minutes.
120s is a deliberate divergence from REPO_POLICIES.md:192, which
mandates a 30-second timeout, and from that file's canonical Go recipe
at :212-214. REPO_POLICIES.md is org-canonical and not editable from
this repo, so the divergence and its reasoning are recorded in
script/test's comment and in TODO.md, and issue #101 proposes amending
the policy text upstream. That issue also carries the trade this
surfaces: the verbose rerun makes a hung package pay the timeout twice,
which at 120s puts a hang-case Docker build over the same policy's
five-minute build limit.
Both invocations in script/test now share one run_tests function so the
quiet run and the verbose rerun cannot drift apart in flags.
Measurements and the full verification are recorded once, on the pull
request, and deliberately not restated here or in TODO.md.
Twelve stale remote branches retired. Nine were ancestors of main with no
unique commits; golangci-v2.12.2 pointed at a tree byte-identical to the
one main's cc58583 already carries; fix/sync-snapshot-cleanup's one-line
change is present on main; and feature/restore-progress-bar was not only
superseded but regressive, since its diff deletes the #28 regression test
that landed separately.
Neither of the two branches that looked like unlanded correctness fixes
turned out to hold one, and in both cases main had moved past them by a
decision already recorded in the tracker.
fix/ctime-scanner-population would have restored a field that no longer
exists: ctime was removed outright by 1c72a37 (#54/#55). It never
participated in change detection either - the scanner compares size,
mtime, mode, uid and gid, exactly the five fields ARCHITECTURE.md
documents - so the feared dedup/data-loss failure was not reachable.
fix/sql-injection-whitelist would have reverted bfd7334, which replaced
a table-name allowlist with regex sanitization in response to review
feedback on PR #32, and would have broken main: its allowlist omits the
snapshots table, whose count main reads with the error discarded, so the
figure would silently have become zero. Exposure on main is nil -
getTableCount is unexported, every call site passes a literal, and the
sanitizing pattern admits no quote, space, semicolon or paren.
feature/daemon-mode is untouched pending the scope decision in #94.
The full disposition inventory, with the evidence for each branch, is
recorded on #71.
PR #89 stopped script/cibuild replaying cached check layers, but left a
gap: a bare `docker build .` with no --build-arg still faked. An unset
ARG is an empty string, an empty string is a stable cache key, and the
check layers replay from it. That gap mattered because REPO_POLICIES.md
names `docker build .` verbatim as a command that must be green, so the
documented command was the one that lied.
Both check stages now carry `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
immediately under their own ARG. Failed steps are never cached, so this
fails on every invocation rather than once - a bare build now stops with
a named error instead of reporting a green it did not earn. Each stage
needs its own guard because ARG scope is per-stage; a gate-carrying stage
without one is a silent hole if ordering ever changes.
The check RUNs now reference the value (`echo "check epoch: ${CHECK_EPOCH}"
&& make <target>`), so the cache miss is contractual rather than resting
on BuildKit's current treatment of unreferenced ARGs, and the epoch is
visible in the build log.
The epoch becomes "$(date +%s%N)$$" so concurrent invocations in the same
second cannot collide. busybox silently drops %N and exits 0, so $$ is
what makes it correct there. The bare-assignment form is retained
deliberately: inlining the substitution into --build-arg would, under
set -eu, yield an empty and therefore constant epoch without aborting.
script/docker gets the same treatment - it is not the gate, but two
entrypoints disagreeing about whether the tree is green is its own
hazard, and local builds are almost always warm.
Verified by negative control rather than inspection: a bare build fails
twice consecutively here and succeeds twice on the parent commit, so the
change is demonstrably not a no-op. The builder-stage guard was fired
directly with a targeted probe build, since the lint stage otherwise
fails first and would leave it unexercised.
script/cibuild was a bare `docker build .`. On an unchanged tree Docker
served the check RUN layers from cache, so make fmt-check, make lint and
make test never executed - and the build still exited 0. Measured at
221ms with zero ok lines and every check layer CACHED, against 162s for a
real run. CI showed the same signature: 6 second "successes" on main.
An ARG CHECK_EPOCH now sits immediately above the check RUNs in both
stages - each stage declares its own, since ARG scope is per-stage - and
script/cibuild passes a fresh value per invocation. Dependency and module
layers sit above the ARG and still cache, so this does not make every
build cold.
The epoch is assigned before the build rather than inlined into the
--build-arg. Under `set -eu` a command substitution that fails inside an
argument does not abort the script: CHECK_EPOCH would become an empty
string, an empty string is a constant, a constant CHECK_EPOCH restores
the cached false green, and the guard would silently disarm itself while
still exiting 0. As a bare assignment, set -e catches a failing date and
no build starts.
The README and Dockerfile state the guarantee conditionally. It holds per
build context and CHECK_EPOCH value, and depends on script/cibuild
passing a fresh one - a bare `docker build .` with no --build-arg still
replays the check layers from the second consecutive run onward. That
residual gap is tracked in #91 along with the remaining upstream
hardening.
Verification is recorded once, in the PR's verification comment, rather
than restated with differing numbers in three places.
The Vaultik.UI doc comment claimed the cli layer replaces the writer with
a discarding writer in --cron mode. It does not. UI is built once as
ui.New(os.Stdout) and never reassigned; internal/cli/app.go calls
UI.SetQuiet(true) when --cron or --quiet is set, which drops Begin,
Complete, Info, Notice, Detail, Progress and Banner - but Warningf and
Errorf have no quiet check and are still emitted.
That distinction matters: the end-of-run summary is deliberately routed
through UI.Warningf so cron delivers something, so a reader who believed
the comment would have concluded the opposite of how the code is meant to
work.
The README's --cron description carried the same imprecision ("Silent
unless error") and is corrected alongside it.
Comment and documentation only - the Go diff contains no non-comment
lines, so there is no behavior change.
ListSnapshots built its table entirely from the local SQLite index. The
only remote access, reportRemoteDrift, was gated on AgeSecretKey != "",
so on a correctly configured host - which by design holds no private key
- snapshot list never contacted the destination store at all. A user who
lost their local index could not see their own backups, and the
"<remote only>" cell the README documents was unreachable dead code.
The listing is now the union of the local index and the destination
store, with no age_secret_key gate. Remote-only snapshots cannot have
their hostname or name recovered - RemoteSnapshotKey is one-way and the
manifest stores the hash - so they are listed by abbreviated remote key
with the real timestamp and compressed size from the manifest, and
"<remote only>" in the two columns that require the local index. Nothing
new is written to remote storage and the human ID is never fabricated.
An unreachable destination degrades to local-only with a warning and a
zero exit code. remote_present is null rather than false in that case,
so "absent" and "unknown" stay distinguishable and no drift is claimed
from a listing that never happened.
Also:
- Snapshot timestamps are normalised to UTC in scanSnapshotRows, the
single point where they enter the domain. Previously one of three
scanners omitted .UTC(), so on a non-UTC host the same snapshot
rendered a different time depending on whether it was locally tracked.
- The 1000-row cap and the unreadable-manifest count are reported in
--json mode as well as table mode, so machine consumers cannot be
silently truncated. The JSON shape is unchanged.
- Warnings raised while listing are routed to stderr rather than the
logger, which writes to stdout and would corrupt the JSON document.
This is a local workaround for the logger bug tracked in #82 and
should be removed when that lands.
- downloadManifestByKey is now the only remote manifest reader, so the
manifest privacy question in #81 has a single call site to change.
- The orphaned "vaultik snapshot cleanup" hint now names vaultik prune;
that command was folded into prune by the 2026-07-02 consolidation.
script/lint ran bare golangci-lint from PATH while CI and the Dockerfile
pinned v2.12.2 by digest, so make lint and CI could disagree about
findings. That drift ran both directions: it produced two false green
claims during the lint remediation, and on an ambient 2.10.1 it also
reported four gosec findings on a tree CI linted clean.
script/lint now extracts the image reference - tag and digest - from the
Dockerfile lint stage FROM line and runs that exact image under docker.
The Dockerfile FROM line is the single source of truth for the linter
version; the duplicate pins in the Makefile deps target and in
script/bootstrap are removed rather than kept in sync.
A golangci-lint on PATH is used only when its version exactly equals the
pin, which is what makes the in-container lint stage work (the Dockerfile
runs make lint inside the pinned image, where there is no docker daemon).
Any other version, or none, goes through docker. When docker is
unavailable the script fails with an actionable message and never falls
back to a different linter version.
script/lint-fix delegates to script/lint --fix so autofixes come from the
pinned linter too. The container mounts persistent build and module
caches and runs as the invoking uid/gid.
Verified by reinstating the four historical nolint directives that 2.10.1
requires and 2.12.2 reports as unused: the old script passed on that tree
and the new one fails with four nolintlint findings.
Clears the final 80 golangci-lint findings under the canonical
.golangci.yml (sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb),
taking the repo from red to green: script/cibuild exits 0.
- wsl_v5 (60): blank line above defer/go statements sharing no variable
with the line above; blank-line-only diff.
- sqlclosecheck (10): the package-local CloseRows helper hid the close
from the analyzer. Helper removed; all 18 call sites now defer an
inline rows.Close(), preserving the fatal-on-close-error path. No
resource leak existed - the rows were always being closed.
- prealloc (3): append targets given a starting capacity.
- revive (3): package-name findings suppressed with per-site directives
pending the naming decision tracked in #76.
No gosec suppressions are needed under the pinned linter. .golangci.yml,
Dockerfile, Makefile, .gitea/ and script/ are byte-identical to main.
Verified with script/cibuild (digest-pinned golangci-lint v2.12.2), not
make check - the latter resolves the linter from PATH and is not a
trustworthy gate here; see #78.
Closes#59.
Updates golangci-lint to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then remediates every finding the new linter/config surfaces so `make check` is green.
## Version bump
- `Dockerfile` lint stage: `golangci/golangci-lint:v2.11.3-alpine` -> `v2.12.2-alpine` (digest-pinned, date comment updated)
- `Makefile` `deps` target: `go install` moved from the old v1 module path at `@latest` to the pinned `github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2`
- `.golangci.yml` replaced with the canonical config (v2 schema; settings under `linters.settings` so the thresholds actually apply; `default: all` with the standard six disables)
- `script/bootstrap` installs golangci-lint via the system package manager and carries no version pin, so it is unchanged
- CI (`.gitea/workflows/check.yml`) only runs `script/cibuild`, so it needed no change
## Lint remediation
The canonical config surfaced ~3,300 findings across 56k lines. All are fixed, behavior-preserving; incorporates and supersedes the per-package mechanical passes already merged to `main` (refs #61). Highlights:
- `err113`: dynamic errors replaced with package sentinels + `%w` wrapping; comparisons via `errors.Is`
- `goprintffuncname`: printf-style helpers renamed with an `f` suffix (`ui.Writer` message methods, `cli.ReportErrorf`, `database.Fatalf`) and all call sites updated
- `revive` stutter renames: `blob.Handler`, `blob.WithReader`, `blob.ChunkPosition`, `storage.URL`, `storage.Info`; missing doc comments added
- `contextcheck`/`noctx`: `context.Context` threaded through `blob.Packer` and the scanner call sites; context-aware `exec`/`sql` variants
- `funlen`/`cyclop`/`gocognit`/`dupl`: oversized and duplicated functions split into focused helpers (production and test code)
- tests: `t.Parallel()` added where safe (global logger init kept in the serial phase for `-race`), `t.TempDir()`/`t.Helper()` adopted, several suites converted to external test packages
- `gosec`: bounded integer conversions, `ReadHeaderTimeout` on the test HTTP server; remaining warnings suppressed per-site with justifications
- remaining `nolint` directives are rare, targeted, and each carries a reason (e.g. `nilnil` not-found contract in the repository layer, fx module globals, on-disk snake_case struct tags)
- removed the deprecated `log.LogOptions` alias (callers migrated to `log.Options`)
`make check` (tests with `-race`, lint, fmt-check) passes.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #62
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
Copied byte-for-byte from the vendored policy set in sneak/prompts.
The new config surfaces 2,990 lint findings; remediation is tracked
in issue #61 rather than being bundled here, so #59 stays open until
make check is green under this config.
Adds `.editorconfig`, copied byte-for-byte from `sneak/dnswatcher` (blob `2fe0ce0`). This is the small, safe half of #59; the `.golangci.yml` half is deferred — adopting the org-standard config surfaces ~2,990 lint findings on vaultik and needs a separate lint-cleanup decision (see #59). Hence `refs #59`, not `closes`.
`make check` and `docker build .` are green (a static config file does not affect them). Left open for review (not merged).
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #60
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
The verb surface accumulated overlapping cleanup commands. Consolidate
so each cleanup verb has one meaning:
- Rename 'database purge' -> 'database delete'. The command removes the
SQLite file entirely; "purge" wrongly suggested purging contents.
- Fold 'snapshot cleanup' into 'prune'. Prune now runs three passes:
reconcile local snapshots against the remote (previously the
standalone cleanup command), drop orphaned local rows, then delete
unreferenced remote blobs. One command, one mental model.
- Delete 'store info'. Its output was a strict subset of 'remote info',
which already prints storage type + location. Any user reaching for
either should reach for 'remote info'.
- Drop 'snapshot remove --all'. It duplicated 'remote nuke --force'.
'remote nuke' is the single supported entry point for wiping the
destination store.
Also update the storage-binding error message to reference the new
'vaultik database delete' name.
The local index tracks which chunks and blobs already exist at the
backup destination. Nothing was recording *which* destination, so
changing storage_url and running a backup left the scanner treating
every already-seen chunk as still-present at the new (empty) location.
Uploads were skipped silently and the resulting snapshots pointed at
blobs that don't exist at the new destination.
Fix: record storage_url in a new local_meta key-value table on first
mutating command, and refuse to proceed when the configured URL later
differs from the stored one. The error explains the two recovery
paths (revert the config, or run 'vaultik database purge' to discard
the index and rebuild from a fresh full backup).
Wired into snapshot create / prune / snapshot remove / snapshot purge
/ snapshot cleanup. Read-only inspection commands (snapshot list,
remote info, store info) are exempt.
chown(2) requires root on every Unix-ish kernel. Restoring 39k files
as a non-root user produces 39k EPERM syscalls plus 39k matching debug
log lines, all for an operation that can't possibly succeed. Skip the
syscall entirely when euid != 0, and emit one warning at the end of the
restore so the user knows the on-disk UID/GID will reflect the running
user rather than the original owner.
The previous change had snapshot rm auto-prune unreferenced blobs. The
correct division of labor is: rm removes a snapshot (local DB + remote
metadata), prune cleans up blobs. Reverting the auto-prune means rm
stays a cheap, deterministic operation: it touches one snapshot's worth
of state and emits the exact 'vaultik prune' command the user should
run next to delete blobs no longer referenced by any remote manifest.
This is correct because prune must consult every remote manifest
(including snapshots this host doesn't know about) to determine which
blobs are still referenced, and folding that work into rm would
silently turn rm into an expensive O(remote snapshots) operation that
also assumes the remote is fully reachable.