Commit Graph

222 Commits

Author SHA1 Message Date
0e2929d75d List remote snapshots without requiring the private key (closes #64)
All checks were successful
check / check (pull_request) Successful in 2m13s
ListSnapshots built its table entirely from the local SQLite index. The
only remote access, reportRemoteDrift, was gated on AgeSecretKey being
non-empty — 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. The manifest is unencrypted, so a
host holding only the public key can enumerate what it has backed up:
one streamed listing of the metadata/ prefix, then a manifest read per
remote key the local index does not already account for, bounded by
maxRemoteOnlyRows and run with bounded concurrency.

A remote-only snapshot's hostname and name are deliberately NOT
recovered. RemoteSnapshotKey is one-way and the manifest stores the
hash rather than the human ID, so they are recoverable only from the
encrypted per-snapshot database; making them readable from remote
storage would undo a deliberate privacy property (#81). Such rows are
labelled "<remote only:<12 hex chars>>", carry the real timestamp and
compressed size from the manifest, and show "<remote only>" in the two
columns that genuinely require the local index. --json carries the full
64-character key in remote_key, and remote_present distinguishes seen
(true), missing (false) and not-listable (null).

Local records with no counterpart on the destination store are still
surfaced as drift, and the remediation hint now names `vaultik prune`,
which exists, rather than `vaultik snapshot cleanup`, which does not:
CleanupLocalSnapshots is already wired as prune's first pass, and
re-adding a second entry point would undo the CLI consolidation.

reportRemoteDrift collapses. Its remote-only half is subsumed by the
table — those snapshots are rows now, not a footnote count — and its
local-only half reads the merge ListSnapshots already computed, so the
command lists the destination exactly once per invocation.

An unreachable destination stays a warning plus local-only output and a
zero exit code, as the doc comment always claimed. In --json mode that
warning goes to stderr, because the logger and the UI writer both emit
on stdout and would otherwise corrupt the document.

Tests cover remote-only rendering, the no-private-key property (a
storer that counts prefix listings and records fetched keys, asserting
the destination is read and nothing encrypted is touched), graceful
degradation on an unreachable destination in both output modes,
local-only drift, and an unreadable manifest not hiding other
snapshots.
2026-08-09 03:15:52 +00:00
0952925453 Route every remote manifest read through downloadManifestByKey
internal/vaultik/verify.go and internal/vaultik/info.go each built the
metadata/<remote-key>/manifest.json.zst path and decoded the manifest
inline, duplicating downloadManifestByKey. Both now call the helper.

The manifest is currently stored compressed but unencrypted, which is
what will let `snapshot list` enumerate the destination store on a host
holding no private key. Whether to encrypt it is still open (#81), and
a single reader means that decision has one call site to change rather
than four.

Refs #81
2026-08-09 03:15:36 +00:00
af607e3597 Run the linter at the pinned version locally too (closes #78)
All checks were successful
check / check (push) Successful in 6s
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.
2026-08-09 04:52:22 +02:00
e496aa334b Finish the lint remediation: script/cibuild exits 0 (closes #61)
All checks were successful
check / check (push) Successful in 5s
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.
2026-08-09 04:25:11 +02:00
cc58583130 Update golangci-lint to v2.12.2 with canonical config (#62)
All checks were successful
check / check (push) Successful in 5s
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>
2026-08-07 23:22:48 +02:00
b87b72d4b9 Update TODO.md: lint remediation chunk 1 complete (refs #61)
Some checks failed
check / check (push) Failing after 52s
2026-08-07 17:00:13 +00:00
e7b49d58ab Fix noinlineerr findings: internal/vaultik (refs #61) 2026-08-07 16:59:56 +00:00
919229f224 Fix noinlineerr findings: internal/storage (refs #61) 2026-08-07 16:59:56 +00:00
68cffba35d Fix noinlineerr findings: internal/snapshot (refs #61) 2026-08-07 16:59:56 +00:00
26cbb63749 Fix noinlineerr findings: internal/pidlock (refs #61) 2026-08-07 16:59:56 +00:00
bf1d3c6bad Fix noinlineerr findings: internal/database (refs #61) 2026-08-07 16:59:56 +00:00
dca3c50cd2 Fix noinlineerr findings: internal/crypto (refs #61) 2026-08-07 16:59:56 +00:00
1ee0d291ca Fix noinlineerr findings: internal/config (refs #61) 2026-08-07 16:59:56 +00:00
2f7e37153c Fix noinlineerr findings: internal/cli (refs #61) 2026-08-07 16:59:56 +00:00
26909b058a Fix noinlineerr findings: internal/chunker (refs #61) 2026-08-07 16:59:56 +00:00
2dfdc5f095 Fix noinlineerr findings: internal/blobgen (refs #61) 2026-08-07 16:59:56 +00:00
217d60eeaa Fix noinlineerr findings: internal/blob (refs #61) 2026-08-07 16:59:56 +00:00
f7ba056814 Fix noinlineerr findings: cmd/vaultik (refs #61) 2026-08-07 16:59:56 +00:00
82eb352eb5 Apply linter autofixes: internal/vaultik (refs #61) 2026-08-07 16:53:23 +00:00
0296e26210 Apply linter autofixes: internal/storage, types, ui (refs #61) 2026-08-07 16:53:23 +00:00
1e05fa0dd7 Apply linter autofixes: internal/snapshot (refs #61) 2026-08-07 16:53:23 +00:00
bec964fc20 Apply linter autofixes: internal/globals, log, models, pidlock, s3 (refs #61) 2026-08-07 16:53:23 +00:00
b1451bb17e Apply linter autofixes: internal/database (refs #61) 2026-08-07 16:53:19 +00:00
ee83f50281 Apply linter autofixes: internal/crypto (refs #61) 2026-08-07 16:53:19 +00:00
76f79af733 Apply linter autofixes: internal/config (refs #61) 2026-08-07 16:53:19 +00:00
070a8a5447 Apply linter autofixes: internal/cli (refs #61) 2026-08-07 16:53:19 +00:00
40516d1263 Apply linter autofixes: internal/chunker (refs #61) 2026-08-07 16:53:19 +00:00
a66e1f9844 Apply linter autofixes: internal/blobgen (refs #61) 2026-08-07 16:53:19 +00:00
5e4df7d04f Apply linter autofixes: internal/blob (refs #61) 2026-08-07 16:53:19 +00:00
d34868a9c4 Apply linter autofixes: cmd/vaultik (refs #61) 2026-08-07 16:53:19 +00:00
04fce150bc Add script/lint-fix entrypoint and make lint-fix shim (refs #61) 2026-08-07 16:40:59 +00:00
23d22a0f19 Add standard .golangci.yml (refs #59)
Some checks failed
check / check (push) Failing after 49s
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.
2026-08-07 16:29:49 +00:00
c9c72ef29d script/bootstrap: install sqlite3, which the test suite shells out to 2026-08-07 16:29:45 +00:00
928c389a5a Add .editorconfig (refs #59) (#60)
All checks were successful
check / check (push) Successful in 4s
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>
2026-08-07 17:46:18 +02:00
2aaeeb4966 Refresh vendored REPO_POLICIES.md
All checks were successful
check / check (push) Successful in 4s
2026-07-07 01:53:18 +02:00
43346e62db Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims 2026-07-07 01:53:18 +02:00
df975bb8f0 Add standard Workflow section to TODO.md
All checks were successful
check / check (push) Successful in 6s
2026-07-06 21:06:38 +02:00
fc56b0cb30 Add TODO.md
All checks were successful
check / check (push) Successful in 6s
2026-07-06 20:35:44 +02:00
1f32820607 Merge branch 'refactor/cli-verb-consolidation'
All checks were successful
check / check (push) Successful in 1m59s
2026-07-02 16:42:24 +02:00
34a4d163f2 Consolidate CLI verbs; retire overlapping commands
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.
2026-07-02 16:42:20 +02:00
9497a31d0f Merge branch 'fix/bind-local-index-to-storage-url'
All checks were successful
check / check (push) Successful in 2m18s
2026-07-02 16:35:44 +02:00
d330f9f031 Bind the local index to its backup destination
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.
2026-07-02 16:35:41 +02:00
fda6d7a7eb Merge branch 'fix/restore-skip-chown-non-root'
All checks were successful
check / check (push) Successful in 2m0s
2026-06-28 07:48:32 +02:00
1a97a80a81 Skip chown when restore runs as non-root; warn at end
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.
2026-06-28 07:48:28 +02:00
d492f34e8d Merge branch 'fix/snapshot-rm-no-auto-prune'
All checks were successful
check / check (push) Successful in 1m54s
2026-06-28 06:20:13 +02:00
c17426b556 snapshot rm: remove metadata only, print prune command for blobs
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.
2026-06-28 06:20:09 +02:00
c06d53ad45 Merge branch 'feature/snapshot-rm-removes-unique-blobs'
All checks were successful
check / check (push) Successful in 2m6s
2026-06-28 06:10:29 +02:00
b39d765374 Make snapshot rm clean up the remote by default
snapshot rm <id> now does the full cleanup: removes the local index
entry, strips the snapshot's metadata from the destination store, and
prunes any blobs that were only referenced by the just-removed manifest.
The --remote flag is retired; --local-only opts out for the rare case
where the user wants to forget a snapshot locally without touching the
remote.

If the destination store is unreachable, the local-DB removal still
completes and a warning is emitted; the user can rerun 'vaultik prune'
to retry the remote half later.

RemoveAllSnapshots gets the same treatment: after deleting every
snapshot's metadata (local + remote + orphan keys), an automatic blob
prune sweep removes the now-unreferenced blob set.
2026-06-28 06:10:26 +02:00
017ad7d3a6 Merge feature/remote-id-hashing-and-resilient-list
All checks were successful
check / check (push) Successful in 2m9s
2026-06-26 01:54:35 +02:00
fd759a921a Hash snapshot IDs at the storage boundary; make snapshot list resilient
Two related changes, both addressing leakage and brittleness around
the public bytes the destination store sees.

First, every remote storage path that previously embedded a human
snapshot ID (e.g. metadata/heraklion_berlin.sneak.fs.photos.2026.
catalog_2026-06-24T07:00:15Z/...) now uses the hashed remote key:

  RemoteSnapshotKey(id) = hex(SHA256(SHA256("vaultik|" + id)))

Applied at:

  * uploadSnapshotArtifacts (snapshot create write path)
  * the manifest.json.zst snapshot_id field — manifest is
    unencrypted, so the human ID would otherwise be readable to
    anyone with bucket-list permission
  * cleanupIncompleteSnapshots metadata-existence probe
  * snapshot restore / verify (downloadSnapshotDB,
    loadVerificationData)
  * downloadManifestByKey, deleteRemoteSnapshotByKey
  * CleanupLocalSnapshots reconciliation
  * the locally-driven removal paths (RemoveSnapshot,
    RemoveAllSnapshots, confirmAndExecutePurge)

The local index database keeps human IDs everywhere — the hash is a
boundary translation, not a rename. A directory listing of the
backup destination now looks like
"metadata/<64-hex>/{db.zst.age,manifest.json.zst}" with no host,
snapshot-name, or timestamp information visible.

Second, snapshot list no longer fails just because remote storage is
unreachable, and only consults the remote when the local machine can
plausibly decrypt:

  * Listing is always driven by the local index database — that's
    what holds the human IDs, timestamps, and per-snapshot stats
    that the table actually shows.
  * If no age secret key is configured, we skip remote listing
    entirely (the box is treated as a write-only backup machine —
    there's no value showing it remote-only keys it could never
    restore).
  * If a key IS configured, we try the remote listing; failures
    (volume unmounted, permission denied, network error) downgrade
    to a warning instead of aborting the command.
  * When the remote listing succeeds, we cross-reference by hashing
    each local human ID and diffing against the returned key set.
    Local-only snapshots get the existing "stale local record"
    cleanup hint; remote-only keys are surfaced as a single
    "NOTE: N remote snapshot(s) found in backup destination store
    but not in local database" line.

FileStorer construction also no longer does an eager mkdir — the
basePath is recorded and the directory is created lazily on first
write. A missing or unmounted destination during `snapshot list`
should NOT block the command, and now it doesn't.

RemoveAllSnapshots is rewritten to drive deletion from the local
index instead of from a remote listing, hashing each local ID to
find the corresponding remote key. Orphan remote keys (no matching
local snapshot) are handled separately and only deleted when
--remote is set. Existing tests are updated to hash storage paths
through the new RemoteSnapshotKey helper.

The hash format is a hard pre-1.0 break: existing remote snapshots
written under the human-ID path scheme are no longer readable; they
need to be either re-uploaded under the new scheme or manually
renamed. There is no fallback path; matching the project policy of
"no migrations pre-1.0."
2026-06-26 01:54:35 +02:00