Compare commits
381
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecfe12e78 | ||
|
|
1aea50d254 | ||
|
|
83981783b0 | ||
|
|
79fccfecce | ||
|
|
252db3259a | ||
|
|
a52a8a781c | ||
|
|
b6f8e860e1 | ||
|
|
64c31f890b | ||
|
|
8f34718a0f | ||
|
|
8654d9b7f5 | ||
|
|
63f8d4df59 | ||
|
|
b6a2c61f82 | ||
|
|
43c9b2c39b | ||
|
|
de9ef62073 | ||
|
|
5abacbc5c1 | ||
|
|
ff50a137dd | ||
|
|
e9b17cc5fd | ||
|
|
850e2de25a | ||
|
|
6a57750a7b | ||
|
|
2590f49583 | ||
|
|
5b8d84e38a | ||
|
|
7a950ea2aa | ||
|
|
16b03dfda5 | ||
|
|
2125a9d451 | ||
|
|
a0de8bfdb7 | ||
|
|
291c39174f | ||
|
|
65036bba1b | ||
|
|
54ad279dd6 | ||
|
|
d156a02c49 | ||
|
|
aeac897805 | ||
|
|
d8f4f62f41 | ||
|
|
9fd88a34c7 | ||
|
|
5db2e920e5 | ||
|
|
66ea7f5f45 | ||
|
|
dd3667c8e9 | ||
|
|
30285c7a8c | ||
|
|
8d5e6f3399 | ||
|
|
abad8161bc | ||
|
|
05a2c86d65 | ||
|
|
d8f78a1f45 | ||
|
|
2c087be577 | ||
|
|
37d525c322 | ||
|
|
f89fac34f7 | ||
|
|
5d8c515192 | ||
|
|
42eb50b151 | ||
|
|
25ee9211d3 | ||
|
|
3361e9ebd9 | ||
|
|
fb8abcd049 | ||
|
|
6aceaaf086 | ||
|
|
3b2b9cec84 | ||
|
|
dfa3bbc9e1 | ||
|
|
740f474c59 | ||
|
|
27e9798da5 | ||
|
|
4193ced1f6 | ||
|
|
e48bafccef | ||
|
|
bffd4578fc | ||
|
|
f4ab1ba5ae | ||
|
|
005927a7fc | ||
|
|
09a0c01c43 | ||
|
|
8c54e0ff75 | ||
|
|
d1213ecb34 | ||
|
|
5f6455ba17 | ||
|
|
0288735aaf | ||
|
|
35eed79b1a | ||
|
|
94412d9ef3 | ||
|
|
167c0a0746 | ||
|
|
07c957f84b | ||
|
|
c9a519c233 | ||
|
|
fa56165fe4 | ||
|
|
34f61fb408 | ||
|
|
05bf73c48a | ||
|
|
79a73fa122 | ||
|
|
0d0368df81 | ||
|
|
c06c4d201b | ||
|
|
70f008a21a | ||
|
|
fce253fa39 | ||
|
|
e459a66099 | ||
|
|
32c4a46b49 | ||
|
|
b5389a62b5 | ||
|
|
d87202fb70 | ||
|
|
e161343eac | ||
|
|
d53202eb86 | ||
|
|
8b22ae8d42 | ||
|
|
3fc8a8f2f4 | ||
|
|
7696f83258 | ||
|
|
5d1118d143 | ||
|
|
b57ce2277d | ||
|
|
8496404d8b | ||
|
|
cdc60c4dfa | ||
|
|
49eed7a3e5 | ||
|
|
85d4ef118d | ||
|
|
b06f992152 | ||
|
|
5d685f03ce | ||
|
|
f59086c0e5 | ||
|
|
d276d891da | ||
|
|
5aa5ba5544 | ||
|
|
315b6483b8 | ||
|
|
14fc4c9893 | ||
|
|
66c80a70e3 | ||
|
|
81f83b29f6 | ||
|
|
c4adb72d80 | ||
|
|
4a167e153a | ||
|
|
ea72697992 | ||
|
|
713be502bd | ||
|
|
35cf985c18 | ||
|
|
070090124a | ||
|
|
584444b619 | ||
|
|
b30e79ee45 | ||
|
|
d886a9026f | ||
|
|
6e1f499048 | ||
|
|
15e6506e5d | ||
|
|
d24f5dc33c | ||
|
|
eed117fe25 | ||
|
|
dd7a610c23 | ||
|
|
1548c0f933 | ||
|
|
c3bec7d3aa | ||
|
|
1244c9e48d | ||
|
|
82c51a5337 | ||
|
|
d88ed64489 | ||
|
|
bd9656dbd4 | ||
|
|
7e611b95db | ||
|
|
4f27608560 | ||
|
|
ae6aaaa388 | ||
|
|
f788668287 | ||
|
|
238ce3985f | ||
|
|
548a7ae156 | ||
|
|
3a58377127 | ||
|
|
a6434de57f | ||
|
|
b4654f8e52 | ||
|
|
39aef1c47c | ||
|
|
96ebcd40d7 | ||
|
|
d9f0220f94 | ||
|
|
4c83e82543 | ||
|
|
86361c8b50 | ||
|
|
d77663d039 | ||
|
|
3abe9cbd9e | ||
|
|
76a6917a35 | ||
|
|
38ebfd843a | ||
|
|
6b7517a4dc | ||
|
|
994e5de613 | ||
|
|
42f4e648d7 | ||
|
|
343129f891 | ||
|
|
a50e3fa038 | ||
|
|
6fcd8e1668 | ||
|
|
aab6a87f8c | ||
|
|
c355ef4d25 | ||
|
|
5927e1aa3d | ||
|
|
9ca962969a | ||
|
|
89ebfc78e2 | ||
|
|
07ef3a1c78 | ||
|
|
c423d13191 | ||
|
|
75a10d3a22 | ||
|
|
3d56dd7eb0 | ||
|
|
bdce350041 | ||
|
|
753bc3ef60 | ||
|
|
d2a0510cb4 | ||
|
|
583f65040a | ||
|
|
d257f8f658 | ||
|
|
696ed9ab4d | ||
|
|
f21e7c9e70 | ||
|
|
c16ef476a9 | ||
|
|
e3f407b440 | ||
|
|
b6e4a218a3 | ||
|
|
c51f693527 | ||
|
|
3f9c2e5033 | ||
|
|
50816b7415 | ||
|
|
c3bb3b5580 | ||
|
|
3bcdbcfd83 | ||
|
|
50e20b460e | ||
|
|
af607e3597 | ||
|
|
e496aa334b | ||
|
|
cc58583130 | ||
|
|
b87b72d4b9 | ||
|
|
e7b49d58ab | ||
|
|
919229f224 | ||
|
|
68cffba35d | ||
|
|
26cbb63749 | ||
|
|
bf1d3c6bad | ||
|
|
dca3c50cd2 | ||
|
|
1ee0d291ca | ||
|
|
2f7e37153c | ||
|
|
26909b058a | ||
|
|
2dfdc5f095 | ||
|
|
217d60eeaa | ||
|
|
f7ba056814 | ||
|
|
82eb352eb5 | ||
|
|
0296e26210 | ||
|
|
1e05fa0dd7 | ||
|
|
bec964fc20 | ||
|
|
b1451bb17e | ||
|
|
ee83f50281 | ||
|
|
76f79af733 | ||
|
|
070a8a5447 | ||
|
|
40516d1263 | ||
|
|
a66e1f9844 | ||
|
|
5e4df7d04f | ||
|
|
d34868a9c4 | ||
|
|
04fce150bc | ||
|
|
23d22a0f19 | ||
|
|
c9c72ef29d | ||
|
|
928c389a5a | ||
|
|
2aaeeb4966 | ||
|
|
43346e62db | ||
|
|
df975bb8f0 | ||
|
|
fc56b0cb30 | ||
|
|
1f32820607 | ||
|
|
34a4d163f2 | ||
|
|
9497a31d0f | ||
|
|
d330f9f031 | ||
|
|
fda6d7a7eb | ||
|
|
1a97a80a81 | ||
|
|
d492f34e8d | ||
|
|
c17426b556 | ||
|
|
c06d53ad45 | ||
|
|
b39d765374 | ||
|
|
017ad7d3a6 | ||
|
|
fd759a921a | ||
|
|
a84b911155 | ||
|
|
5ce1dfa39e | ||
|
|
aa3e8f081b | ||
|
|
1f22b9c603 | ||
|
|
60abeb636a | ||
|
|
7ae49a1b2c | ||
|
|
a92b1a82ad | ||
|
|
39d5d21d48 | ||
|
|
44c9008e7e | ||
|
|
8036d93914 | ||
|
|
b55d5763ad | ||
|
|
53febb48d2 | ||
|
|
d55ddc5914 | ||
|
|
d9319dc0fb | ||
|
|
af330f2777 | ||
|
|
683fb0b103 | ||
|
|
cf8a527d35 | ||
|
|
a63c729fbc | ||
|
|
a1065d4f1f | ||
|
|
0e9c96c8b5 | ||
|
|
cafae65f61 | ||
|
|
7a0d5bfd73 | ||
|
|
8d1c8982d7 | ||
|
|
e75367c594 | ||
|
|
64c69cd8e3 | ||
|
|
132f7149ca | ||
|
|
f1ce085972 | ||
|
|
d8edf90fac | ||
|
|
301ea217e8 | ||
|
|
9f537b9c4c | ||
|
|
cf5b643bee | ||
|
|
3113014b58 | ||
|
|
706284d590 | ||
|
|
75564a504e | ||
|
|
edd3e5fdb2 | ||
|
|
d5796bd6c1 | ||
|
|
90e855ef99 | ||
|
|
2185421c01 | ||
|
|
ce0d7b45a1 | ||
|
|
1266a263fc | ||
|
|
70632e4353 | ||
|
|
77b9d943e4 | ||
|
|
fc4d0d6dc7 | ||
|
|
22227aa0c5 | ||
|
|
9cb14d143d | ||
|
|
00d4b36e35 | ||
|
|
8de8f8e5cc | ||
|
|
6e6e107243 | ||
|
|
6bb6f7c8a8 | ||
|
|
8e55d2f970 | ||
|
|
b0747657e3 | ||
|
|
2a9718855c | ||
|
|
485f3296d9 | ||
|
|
adf73c5413 | ||
|
|
8959741c90 | ||
|
|
e534746cf3 | ||
|
|
5397b37c13 | ||
|
|
2df2792a75 | ||
|
|
4fe568f803 | ||
|
|
27e85f01f2 | ||
|
|
d479bfcd52 | ||
|
|
cb16d6869f | ||
|
|
ff85f1e4f8 | ||
|
|
b2e160944f | ||
|
|
307867f59e | ||
|
|
9d12d500fa | ||
|
|
2e2bf01130 | ||
|
|
e9687c68b7 | ||
|
|
a8970a87fc | ||
|
|
e6ee488d9d | ||
|
|
2e2b02a056 | ||
|
|
0b95cb4308 | ||
|
|
4a3e61f8e1 | ||
|
|
6fbcac0cd8 | ||
|
|
34f73f72d8 | ||
|
|
ee240faa32 | ||
|
|
f719ab3adc | ||
|
|
1a8baf7491 | ||
|
|
7d5d3fa598 | ||
|
|
ac5d2f4a0d | ||
|
|
b250ddfa94 | ||
|
|
fe3ad13a91 | ||
|
|
ebd6619638 | ||
|
|
20d3a9ac8c | ||
|
|
0889cf2804 | ||
|
|
f9ebb4bf25 | ||
|
|
9f2d722734 | ||
|
|
6821215b0e | ||
|
|
f97a1dc2eb | ||
|
|
18c14d1507 | ||
|
|
65da291ddf | ||
|
|
dcf3ec399a | ||
|
|
495dede1bc | ||
|
|
1c72a37bc8 | ||
|
|
60b6746db9 | ||
|
|
f28c8a73b7 | ||
|
|
1c0f5b8eb2 | ||
|
|
689109a2b8 | ||
|
|
ac2f21a89d | ||
|
|
8c59f55096 | ||
|
|
c24e7e6360 | ||
|
|
7a5943958d | ||
|
|
d8a51804d2 | ||
|
|
76f4421eb3 | ||
|
|
53ac868c5d | ||
|
|
8c4ea2b870 | ||
|
|
597b560398 | ||
|
|
1e2eced092 | ||
|
|
815b35c7ae | ||
|
|
9c66674683 | ||
|
|
49de277648 | ||
|
|
ed5d777d05 | ||
|
|
76e047bbb2 | ||
|
|
2e7356dd85 | ||
|
|
70d4fe2aa0 | ||
|
|
2f249e3ddd | ||
|
|
3f834f1c9c | ||
|
|
9879668c31 | ||
|
|
0a0d9f33b0 | ||
|
|
df0e8c275b | ||
|
|
ddc23f8057 | ||
|
|
cafb3d45b8 | ||
|
|
d77ac18aaa | ||
|
|
825f25da58 | ||
|
|
162d76bb38 | ||
|
|
bfd7334221 | ||
|
|
9b32bf0846 | ||
|
|
8adc668fa6 | ||
|
|
441c441eca | ||
|
|
4d9f912a5f | ||
|
|
46c2ea3079 | ||
|
|
470bf648c4 | ||
|
|
bdaaadf990 | ||
|
|
417b25a5f5 | ||
|
|
2afd54d693 | ||
|
|
05286bed01 | ||
|
|
f2c120f026 | ||
|
|
bbe09ec5b5 | ||
|
|
43a69c2cfb | ||
|
|
899448e1da | ||
|
|
24c5e8c5a6 | ||
|
|
40fff09594 | ||
|
|
8a8651c690 | ||
|
|
a1d559c30d | ||
|
|
88e2508dc7 | ||
|
|
c3725e745e | ||
|
|
badc0c07e0 | ||
|
|
cda0cf865a | ||
|
|
0736bd070b | ||
|
|
d7cd9aac27 | ||
|
|
bb38f8c5d6 | ||
|
|
e29a995120 | ||
|
|
5c70405a85 | ||
|
|
a544fa80f2 | ||
|
|
c07d8eec0a | ||
|
|
0cbb5aa0a6 | ||
|
|
fb220685a2 | ||
|
|
1d027bde57 | ||
|
|
bb2292de7f | ||
|
|
d3afa65420 | ||
|
|
78af626759 | ||
|
|
86b533d6ee | ||
|
|
26db096913 | ||
|
|
36c59cb7b3 |
No files matched your search
@@ -0,0 +1,79 @@
|
|||||||
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
|
# into .gitignore, where `**/` is wrong.
|
||||||
|
#
|
||||||
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
|
#
|
||||||
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
|
# its own .git directory when it keeps one.
|
||||||
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
|
# `**/.git/modules/**/config` also matches that segment's directory
|
||||||
|
# under .git/modules/. Go's version stamping then fails the build;
|
||||||
|
# nothing leaks. Name such a submodule without that segment:
|
||||||
|
# `git submodule add --name`.
|
||||||
|
**/.git/config
|
||||||
|
**/.git/modules/**/config
|
||||||
|
|
||||||
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
|
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||||
|
# `services/api/.claude/` and must add its own anchored entry.
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Re-include a committed template with a negation if the
|
||||||
|
# build needs one: `!docs/example.env`.
|
||||||
|
**/*.[eE][nN][vV]
|
||||||
|
**/.[eE][nN][vV].*
|
||||||
|
**/.[eE][nN][vV][rR][cC]
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them. Public certificates
|
||||||
|
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||||
|
**/*.[pP][eE][mM]
|
||||||
|
**/*.[kK][eE][yY]
|
||||||
|
**/*.[pP]12
|
||||||
|
**/*.[pP][fF][xX]
|
||||||
|
**/[iI][dD]_[rR][sS][aA]
|
||||||
|
**/[iI][dD]_[dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
**/[iI][dD]_[eE][dD]25519
|
||||||
|
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Dependencies: restored inside the image, never copied in.
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# OS metadata.
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Editor state: never a build input, and it churns COPY.
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/*.bak
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.sublime-*
|
||||||
|
|
||||||
|
# This repo's own host-built artifacts.
|
||||||
|
/vaultik
|
||||||
|
/dist
|
||||||
|
/.tool
|
||||||
|
/coverage.out
|
||||||
|
/coverage.html
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
name: check
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- run: script/cibuild
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout v4, 2024-09-16
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
# goreleaser needs the tags and the full history: the version
|
||||||
|
# it stamps comes from the tag, and the changelog comes from
|
||||||
|
# the commits since the previous one. A shallow checkout
|
||||||
|
# silently produces a mislabelled release.
|
||||||
|
fetch-depth: 0
|
||||||
|
# goreleaser is not a compiler: it shells out to `go` for the
|
||||||
|
# `before:` hook and for every one of the four cross-compiles.
|
||||||
|
# Without this step the release either fails at the before-hook or,
|
||||||
|
# worse, ships binaries built by whatever Go the runner happens to
|
||||||
|
# carry. check.yml's runner gets the same Go through
|
||||||
|
# script/bootstrap, which calls script/install-go, and uses it only
|
||||||
|
# for `go mod download` and gofmt; it compiles inside the
|
||||||
|
# digest-pinned Dockerfile images.
|
||||||
|
#
|
||||||
|
# actions/setup-go would pin the action by commit sha, but the Go
|
||||||
|
# tarball it downloads at runtime is verified against no value in
|
||||||
|
# this repo, and the action exposes no checksum input.
|
||||||
|
# REPO_POLICIES.md requires every external reference to be pinned
|
||||||
|
# by hash with no exceptions, and this is the compiler that
|
||||||
|
# produces the published binaries -- the input where a substituted
|
||||||
|
# artifact matters most. So Go is installed the way goreleaser is:
|
||||||
|
# script/install-go downloads the exact archive for go.mod's `go`
|
||||||
|
# directive and refuses it unless its sha256 matches the value
|
||||||
|
# committed in the script, then puts .tool/go/bin on PATH for the
|
||||||
|
# steps below.
|
||||||
|
- name: Install Go
|
||||||
|
run: script/install-go
|
||||||
|
- name: Install goreleaser
|
||||||
|
run: script/install-goreleaser
|
||||||
|
- name: Release
|
||||||
|
run: script/release
|
||||||
|
env:
|
||||||
|
# RELEASE_TOKEN is a repository Actions secret: a Gitea access
|
||||||
|
# token with write access to this repository's releases (scope
|
||||||
|
# write:repository), owned by an account that can publish here.
|
||||||
|
# It is deliberately not the runner's automatic token, which is
|
||||||
|
# not guaranteed to carry that scope.
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
# Build with the toolchain install-go just verified, never a
|
||||||
|
# different one auto-downloaded from a `toolchain` directive:
|
||||||
|
# the point of the hash pin is that this exact compiler makes
|
||||||
|
# the release.
|
||||||
|
GOTOOLCHAIN: local
|
||||||
+56
-16
@@ -1,22 +1,62 @@
|
|||||||
# Binary
|
|
||||||
vaultik
|
|
||||||
|
|
||||||
# Test artifacts
|
|
||||||
*.out
|
|
||||||
*.test
|
|
||||||
coverage.html
|
|
||||||
coverage.out
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Local config for development
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Secrets. Unanchored like every entry above, so each matches at every
|
||||||
|
# depth. Matching is case-sensitive on Linux, so names use character
|
||||||
|
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Only the templates `example.env` and `sample.env` are
|
||||||
|
# re-included below. A repository that commits any other template adds
|
||||||
|
# its own negation after these lines, for example `!.env.example`.
|
||||||
|
*.[eE][nN][vV]
|
||||||
|
.[eE][nN][vV].*
|
||||||
|
.[eE][nN][vV][rR][cC]
|
||||||
|
!example.env
|
||||||
|
!sample.env
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them.
|
||||||
|
*.[pP][eE][mM]
|
||||||
|
*.[kK][eE][yY]
|
||||||
|
*.[pP]12
|
||||||
|
*.[pP][fF][xX]
|
||||||
|
[iI][dD]_[rR][sS][aA]
|
||||||
|
[iI][dD]_[dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
[iI][dD]_[eE][dD]25519
|
||||||
|
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Go build and test output.
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# This repo's own host-built artifacts.
|
||||||
|
/vaultik
|
||||||
|
/dist/
|
||||||
|
/.tool/
|
||||||
|
|
||||||
|
# Local configs for development; they hold storage credentials.
|
||||||
local-config.yaml
|
local-config.yaml
|
||||||
dev-config.yaml
|
dev-config.yaml
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
modules-download-mode: readonly
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
|
disable:
|
||||||
|
# Genuinely incompatible with project patterns
|
||||||
|
- exhaustruct # Requires all struct fields
|
||||||
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
|
- godot # Requires comments to end with periods
|
||||||
|
- wrapcheck # Too verbose for internal packages
|
||||||
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
|
settings:
|
||||||
|
lll:
|
||||||
|
line-length: 88
|
||||||
|
funlen:
|
||||||
|
lines: 80
|
||||||
|
statements: 50
|
||||||
|
cyclop:
|
||||||
|
max-complexity: 15
|
||||||
|
dupl:
|
||||||
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# test-support package exists to hand a test privileges the program
|
||||||
|
# itself must never have, so a file that is not a test must not import
|
||||||
|
# one. Test files, and the files inside a package whose directory name
|
||||||
|
# ends in `test`, are where that code belongs, and are exempt.
|
||||||
|
#
|
||||||
|
# The deny list below is the one part of this file a repository is
|
||||||
|
# expected to extend, and the only part it may. depguard matches an
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
|
deny:
|
||||||
|
- pkg: net/http/httptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
- pkg: sneak.berlin/go/vaultik/internal/storage/faultstore
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
# Only decisions already recorded in the Go package defaults are
|
||||||
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
|
blocked:
|
||||||
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
|
- log/slog
|
||||||
|
reason: "Structured logging is stdlib log/slog."
|
||||||
|
# One entry per pre-fork module path, because the later releases
|
||||||
|
# are separate paths. A prefix match would be shorter but would
|
||||||
|
# also reach github.com/go-redis/redismock, the test double for
|
||||||
|
# the successor these entries recommend.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/sergi/go-diff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: vaultik
|
||||||
|
|
||||||
|
# This repo lives on Gitea, not GitHub. Without this block goreleaser
|
||||||
|
# talks to the GitHub API by default and a `goreleaser release` either
|
||||||
|
# fails outright or publishes somewhere nobody is looking.
|
||||||
|
gitea_urls:
|
||||||
|
api: https://git.eeqj.de/api/v1
|
||||||
|
download: https://git.eeqj.de
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: vaultik
|
||||||
|
main: ./cmd/vaultik
|
||||||
|
binary: vaultik
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X 'sneak.berlin/go/vaultik/internal/globals.Version={{ .Version }}'
|
||||||
|
- -X 'sneak.berlin/go/vaultik/internal/globals.Commit={{ .Commit }}'
|
||||||
|
- -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate={{ slice .CommitDate 0 10 }}'
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: default
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
formats:
|
||||||
|
- tar.gz
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: "checksums.txt"
|
||||||
|
algorithm: sha256
|
||||||
|
|
||||||
|
# A snapshot is not a release and must not name itself like one. The
|
||||||
|
# previous `{{ incpatch .Version }}-next` derived a plausible-looking
|
||||||
|
# release number from the last tag -- and with no tags in the repo at
|
||||||
|
# all, from goreleaser's fabricated v0.0.0.
|
||||||
|
snapshot:
|
||||||
|
version_template: "dev-{{ slice .FullCommit 0 12 }}"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
use: git
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^test:"
|
||||||
|
- "^chore:"
|
||||||
|
- "Merge pull request"
|
||||||
|
- "Merge branch"
|
||||||
|
|
||||||
|
release:
|
||||||
|
draft: true
|
||||||
|
prerelease: auto
|
||||||
@@ -38,10 +38,9 @@ Version: 2025-06-08
|
|||||||
1. Before committing, tests must pass (`make test`), linting must pass
|
1. Before committing, tests must pass (`make test`), linting must pass
|
||||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||||
`golangci-lint run`. When you think your changes are complete, rather
|
`golangci-lint run`. Each Makefile target does exactly one thing — to
|
||||||
than making three different tool calls to check, you can just run `make
|
run lint + fmt-check + test together (the standard pre-commit gate),
|
||||||
test && make fmt && make lint` as a single tool call which will save
|
use `make check`.
|
||||||
time.
|
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with
|
2. Always write a `Makefile` with the default target being `test`, and with
|
||||||
a `fmt` target that formats the code. The `test` target should run all
|
a `fmt` target that formats the code. The `test` target should run all
|
||||||
@@ -84,8 +83,8 @@ Version: 2025-06-08
|
|||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
9. Always use structured logging. Log any relevant state/context with the
|
||||||
messages (but do not log secrets). If stdout is not a terminal, output
|
messages (but do not log secrets). If the log stream is not a terminal,
|
||||||
the structured logs in jsonl format.
|
output the structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||||
anywhere more than once. Always define a constant (usually at the top
|
anywhere more than once. Always define a constant (usually at the top
|
||||||
@@ -103,3 +102,29 @@ Version: 2025-06-08
|
|||||||
build files are acceptable in the root, but source code and other files
|
build files are acceptable in the root, but source code and other files
|
||||||
should be organized in appropriate subdirectories.
|
should be organized in appropriate subdirectories.
|
||||||
|
|
||||||
|
13. Pre-1.0: NEVER add a database migration. Migrations are supported, but
|
||||||
|
nothing is installed anywhere yet, so there is nothing to migrate. To
|
||||||
|
change the schema, edit `internal/database/schema/001.sql` (and any
|
||||||
|
code that touches the affected tables) directly. After 1.0, each schema
|
||||||
|
change is a new numbered file in that directory and a released file is
|
||||||
|
never edited; an existing local database is then migrated when vaultik
|
||||||
|
is updated. Before 1.0, a local database left on an older schema is
|
||||||
|
deleted and re-created by a full backup. See
|
||||||
|
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
|
|
||||||
|
14. Never use `git add -A`. Stage only the files you intentionally
|
||||||
|
changed.
|
||||||
|
|
||||||
|
15. Commit messages carry no attribution or advertising trailers for the
|
||||||
|
tool that helped write the code, or for its vendor: the owner is the
|
||||||
|
sole author of code written with a tool.
|
||||||
|
|
||||||
|
16. Run the whole test suite with `make test` every time, and read its full
|
||||||
|
output. Never run `go test`, a single test or a single package, and
|
||||||
|
never grep the output.
|
||||||
|
|
||||||
|
17. Do not stop working on a task until the definition of done given in the
|
||||||
|
initial instruction is met: all of the work, not part or most of it.
|
||||||
|
|
||||||
|
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server
|
||||||
|
backed by 2000MB/sec SSD takes about 4 seconds per gigabyte.
|
||||||
+401
@@ -0,0 +1,401 @@
|
|||||||
|
# vaultik architecture
|
||||||
|
|
||||||
|
This document describes how the `vaultik` code is organised. It covers three
|
||||||
|
things:
|
||||||
|
|
||||||
|
- the packages
|
||||||
|
- how they are wired together
|
||||||
|
- what happens during a backup, a restore, a verify and a prune
|
||||||
|
|
||||||
|
Two other documents cover the stored data:
|
||||||
|
|
||||||
|
- [docs/DATAMODEL.md](docs/DATAMODEL.md) describes the local index.
|
||||||
|
- [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md) describes the layout of the
|
||||||
|
destination.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
||||||
|
- **Chunk**: a piece of a file's content. A chunk is named by the SHA-256 hash
|
||||||
|
of its content.
|
||||||
|
- **Blob**: one object at the destination. It holds many chunks, compressed and
|
||||||
|
then encrypted.
|
||||||
|
- **Local index**: the SQLite database on the backed-up machine.
|
||||||
|
- **Destination**: the place the backups are stored.
|
||||||
|
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
|
||||||
|
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
|
||||||
|
at the destination.
|
||||||
|
|
||||||
|
## How a backup moves data
|
||||||
|
|
||||||
|
```
|
||||||
|
source files
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Scanner walks the paths and finds the files that changed
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Chunker cuts each changed file into chunks
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Packer packs new chunks into a blob, compressed and encrypted
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Storer uploads the blob to the destination
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `cmd/vaultik`: the `main` function. It starts optional profiling and calls
|
||||||
|
`cli.Entry`.
|
||||||
|
- `internal/cli`: the commands, their flags and their help text. It builds the
|
||||||
|
application, takes the lock and handles interrupts.
|
||||||
|
- `internal/vaultik`: the code behind each command. `Vaultik` holds every
|
||||||
|
dependency a command uses.
|
||||||
|
- `internal/snapshot`: the `Scanner`, which backs up one path, and the
|
||||||
|
`SnapshotManager`, which creates snapshot records and uploads their metadata.
|
||||||
|
- `internal/chunker`: cuts a stream into chunks with the FastCDC algorithm.
|
||||||
|
- `internal/blob`: the `Packer`, which collects chunks into blobs.
|
||||||
|
- `internal/blobgen`: the writer that hashes, compresses and encrypts a blob,
|
||||||
|
and the reader that reverses it.
|
||||||
|
- `internal/database`: the local index. It holds the schema and the repository
|
||||||
|
types that read and write the tables.
|
||||||
|
[docs/DATAMODEL.md](docs/DATAMODEL.md#code) says which type serves which
|
||||||
|
table.
|
||||||
|
- `internal/storage`: the `Storer` interface and its three implementations, for
|
||||||
|
S3, a local directory and rclone.
|
||||||
|
- `internal/s3`: the S3 client that the S3 `Storer` uses.
|
||||||
|
- `internal/config`: loads and checks the config file.
|
||||||
|
- `internal/ui`: formats the status lines, warnings and errors a user sees.
|
||||||
|
- `internal/log`: the logger. It writes to stderr.
|
||||||
|
- `internal/pidlock`: the lock that keeps two data-changing commands from
|
||||||
|
running at once.
|
||||||
|
- `internal/globals`: the version, commit and commit date, set when the binary
|
||||||
|
is built.
|
||||||
|
- `internal/types`: distinct types for IDs, hashes and paths.
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
The application is assembled with [uber-go/fx](https://github.com/uber-go/fx), a
|
||||||
|
dependency injection library. `cli.NewApp` lists the providers:
|
||||||
|
|
||||||
|
```go
|
||||||
|
fx.Supply(config.Path(opts.ConfigPath)),
|
||||||
|
fx.Supply(opts.LogOptions),
|
||||||
|
fx.Provide(globals.New),
|
||||||
|
fx.Provide(log.New),
|
||||||
|
config.Module,
|
||||||
|
database.Module,
|
||||||
|
log.Module,
|
||||||
|
storage.Module,
|
||||||
|
snapshot.Module,
|
||||||
|
fx.Provide(vaultik.New),
|
||||||
|
fx.Invoke(setupGlobals),
|
||||||
|
fx.NopLogger,
|
||||||
|
```
|
||||||
|
|
||||||
|
Each module provides these:
|
||||||
|
|
||||||
|
- `config.Module` provides `*config.Config`, from `config.Load`.
|
||||||
|
- `database.Module` provides `*database.DB` and `*database.Repositories`. It
|
||||||
|
creates the index file with mode `0600` when it does not exist.
|
||||||
|
- `storage.Module` provides the `storage.Storer` that the config selects.
|
||||||
|
- `snapshot.Module` provides the `snapshot.ScannerFactory` and the
|
||||||
|
`*snapshot.SnapshotManager`.
|
||||||
|
- `vaultik.New` provides `*vaultik.Vaultik`.
|
||||||
|
|
||||||
|
A command runs through `cli.RunOperation`. It works like this:
|
||||||
|
|
||||||
|
- `RunWithApp` takes the lock when the command changes data, and then starts the
|
||||||
|
application.
|
||||||
|
- The command's function runs in its own goroutine.
|
||||||
|
- When the function returns, it asks the application to shut down.
|
||||||
|
- On `SIGINT` or `SIGTERM`, the stop hook cancels the `Vaultik` context. It then
|
||||||
|
waits for the function to return, so that its temporary files are removed.
|
||||||
|
- The wait ends after 30 seconds at most.
|
||||||
|
|
||||||
|
The `config` commands, `database delete`, `completion` and `version` do not
|
||||||
|
build the application.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
`Vaultik.CreateSnapshot` runs a backup. It does these steps in order:
|
||||||
|
|
||||||
|
- `EnsureStorageBinding` checks that the local index belongs to the configured
|
||||||
|
destination.
|
||||||
|
- `markDestination` checks the marker file and then writes it.
|
||||||
|
- `PruneDatabase` removes unfinished snapshots and unused records from the
|
||||||
|
local index.
|
||||||
|
- `createNamedSnapshot` runs once for each snapshot name.
|
||||||
|
- With `--prune`, `runPostBackupPrune` removes old snapshots and unused data.
|
||||||
|
|
||||||
|
`CreateSnapshot` calls `skipPostBackupPrune` instead of `runPostBackupPrune` in
|
||||||
|
these cases:
|
||||||
|
|
||||||
|
- `--skip-errors` skipped a file or directory that could not be read.
|
||||||
|
- A configured path held nothing.
|
||||||
|
- An earlier snapshot of the same name found a mount point that this backup did
|
||||||
|
not find.
|
||||||
|
|
||||||
|
`PruneDatabase` must succeed before anything is scanned. An unfinished snapshot
|
||||||
|
left in the index would make the backup skip files that were never fully
|
||||||
|
stored.
|
||||||
|
|
||||||
|
### One snapshot
|
||||||
|
|
||||||
|
`createNamedSnapshot` does these steps:
|
||||||
|
|
||||||
|
- `resolveSnapshotPaths` makes each configured path absolute and resolves
|
||||||
|
symlinks.
|
||||||
|
- `SnapshotManager.CreateSnapshotWithName` creates the snapshot record. The
|
||||||
|
record is not yet marked complete.
|
||||||
|
- `Scanner.Scan` backs up each path in turn.
|
||||||
|
- `finalizeSnapshotMetadata` uploads the snapshot's metadata and marks the
|
||||||
|
snapshot complete.
|
||||||
|
|
||||||
|
A new `Scanner` is created for each snapshot, from `ScannerFactory`. It owns a
|
||||||
|
`Chunker` and a `Packer`.
|
||||||
|
|
||||||
|
### Scanning one path
|
||||||
|
|
||||||
|
`Scanner.Scan` works in these steps:
|
||||||
|
|
||||||
|
- `repairInterruptedBlobs` deletes the records of blobs whose upload never
|
||||||
|
finished. Their data is then stored again.
|
||||||
|
- `loadDatabaseState` loads the known files and chunks into memory.
|
||||||
|
- `scanPhase` walks the path. It sorts each entry into changed or unchanged.
|
||||||
|
- `detectDeletedFiles` counts the files that the previous snapshot of this name
|
||||||
|
had and that are now gone.
|
||||||
|
- `batchAddFilesToSnapshot` records the unchanged files in the new snapshot.
|
||||||
|
- `recordDirectories` records every directory, and the mount points found.
|
||||||
|
- `processPhase` reads each changed file and stores it.
|
||||||
|
|
||||||
|
The walk handles each kind of entry differently:
|
||||||
|
|
||||||
|
- A regular file or a symlink is compared with its record in the index.
|
||||||
|
- A directory is recorded with its metadata. It has no content to store.
|
||||||
|
- A device node, a named pipe or a socket is skipped.
|
||||||
|
- An entry that matches an exclude pattern is skipped. For a directory, so is
|
||||||
|
everything in it.
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
|
||||||
|
Data is stored once at two levels.
|
||||||
|
|
||||||
|
- **Files.** A file is not read again when its record in the index still matches
|
||||||
|
it.
|
||||||
|
- **Chunks.** A chunk is named by the SHA-256 hash of its content. A chunk whose
|
||||||
|
hash is already in the index is not stored again.
|
||||||
|
|
||||||
|
A file's record matches when all of these are unchanged:
|
||||||
|
|
||||||
|
- the size
|
||||||
|
- the modification time
|
||||||
|
- the mode
|
||||||
|
- the owner and group
|
||||||
|
- the link target
|
||||||
|
|
||||||
|
One case overrides the file check. A file is read again when it lists a chunk
|
||||||
|
that no uploaded blob holds. This happens when the only snapshot that used the
|
||||||
|
blob holding that chunk is removed. `loadFilesToRechunk` finds these files.
|
||||||
|
|
||||||
|
### Chunks
|
||||||
|
|
||||||
|
`chunker.Chunker` cuts a file with the FastCDC algorithm. The cut points depend
|
||||||
|
on the content, so an insertion early in a file moves only nearby chunks.
|
||||||
|
|
||||||
|
`chunk_size` sets the average chunk size. No chunk is larger than four times
|
||||||
|
this size. Only a file's last chunk can be smaller than a quarter of it. With
|
||||||
|
the default of 10 MB, those limits are 40 MB and 2.5 MB.
|
||||||
|
|
||||||
|
`ChunkReaderStreaming` passes each chunk to a callback as it is found. The whole
|
||||||
|
file is never held in memory.
|
||||||
|
|
||||||
|
### Blobs
|
||||||
|
|
||||||
|
`blob.Packer` collects new chunks into a blob. For each blob it does this:
|
||||||
|
|
||||||
|
- It gives the blob a UUID and creates a temporary file.
|
||||||
|
- It adds each chunk's bytes to a running SHA-256 hash.
|
||||||
|
- In the same pass it compresses the bytes with zstd and encrypts them with
|
||||||
|
age. The result goes to the temporary file.
|
||||||
|
- It records each chunk's offset and length within the blob.
|
||||||
|
- It closes the blob when the next chunk would pass `blob_size_limit`, and at
|
||||||
|
the end of each path.
|
||||||
|
|
||||||
|
The blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. It is computed
|
||||||
|
from the chunk data before compression and encryption.
|
||||||
|
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs) says what follows from
|
||||||
|
that.
|
||||||
|
|
||||||
|
When a blob is closed, `Scanner.handleBlobReady` stores it:
|
||||||
|
|
||||||
|
- It looks the blob up at the destination. A blob that is already there is not
|
||||||
|
uploaded again.
|
||||||
|
- Otherwise it uploads the temporary file to
|
||||||
|
`blobs/<hash[0:2]>/<hash[2:4]>/<hash>`.
|
||||||
|
- It records the upload in the index and links the blob to the snapshot.
|
||||||
|
- It deletes the temporary file.
|
||||||
|
|
||||||
|
A file's record is written to the index only after every blob that holds its
|
||||||
|
chunks is uploaded.
|
||||||
|
|
||||||
|
### Snapshot metadata
|
||||||
|
|
||||||
|
`finalizeSnapshotMetadata` does these steps in order:
|
||||||
|
|
||||||
|
- `PopulateSnapshotBlobs` records every blob the snapshot uses.
|
||||||
|
- `UpdateSnapshotStatsExtended` stores the snapshot's totals.
|
||||||
|
- `ExportSnapshotMetadata` uploads the metadata.
|
||||||
|
- `MarkSnapshotComplete` marks the snapshot complete.
|
||||||
|
|
||||||
|
Completion is recorded last on purpose. A backup that stops during the upload
|
||||||
|
leaves the snapshot unfinished, and the next backup removes it and stores its
|
||||||
|
data again.
|
||||||
|
|
||||||
|
`ExportSnapshotMetadata` works on a copy of the index, never on the index
|
||||||
|
itself:
|
||||||
|
|
||||||
|
- It copies the index into a temporary directory with `VACUUM INTO`.
|
||||||
|
- It deletes the rows of every other snapshot from the copy. It also deletes the
|
||||||
|
files and blobs that this snapshot does not use. It then deletes each chunk
|
||||||
|
that no remaining file and no remaining blob uses.
|
||||||
|
- It keeps the `local_meta` and `schema_migrations` tables as they are. The
|
||||||
|
uploaded database therefore names the destination, and lists the migrations
|
||||||
|
that have run.
|
||||||
|
- It runs `VACUUM`, so that no deleted row is left in the file.
|
||||||
|
- It compresses the copy with zstd and encrypts it with age.
|
||||||
|
- It builds the manifest from the copy.
|
||||||
|
- It uploads `metadata/<remote-key>/db.zst.age`, and then
|
||||||
|
`metadata/<remote-key>/manifest.json.zst`.
|
||||||
|
|
||||||
|
The manifest is uploaded last. A directory under `metadata/` with no manifest
|
||||||
|
therefore marks a backup that did not finish.
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
`Vaultik.Restore` does these steps:
|
||||||
|
|
||||||
|
- `restoreIdentities` parses the age private key. A missing or malformed key
|
||||||
|
stops the restore before anything is downloaded.
|
||||||
|
- `downloadSnapshotDB` downloads and decrypts the snapshot's database into a
|
||||||
|
temporary directory.
|
||||||
|
- `getFilesToRestore` selects the files, from the paths given.
|
||||||
|
- `restoreAllFiles` downloads blobs and writes the files.
|
||||||
|
- With `--verify`, `verifyRestoredFiles` reads each restored file and checks its
|
||||||
|
chunk hashes.
|
||||||
|
|
||||||
|
The restore does not read the local index or the manifest. It finds each file's
|
||||||
|
chunks and blobs through the downloaded database.
|
||||||
|
|
||||||
|
### Checks on the downloaded database
|
||||||
|
|
||||||
|
Decryption shows that a database is readable. It does not show that the
|
||||||
|
database is honest. The restore therefore checks it:
|
||||||
|
|
||||||
|
- `database.OpenReadOnly` opens the file read-only. It refuses a file that has a
|
||||||
|
trigger, a view or a virtual table, or that lacks an expected table.
|
||||||
|
- `verifySnapshotDBIdentity` checks that the snapshot in the database hashes to
|
||||||
|
the remote key that was asked for.
|
||||||
|
- `containedRestorePath` refuses a stored path that would leave the target
|
||||||
|
directory. It also refuses to write through a symlink below the target.
|
||||||
|
- A blob is not decompressed beyond the size that the database gives for it.
|
||||||
|
- Each downloaded blob is hashed. The hash must match the blob's name.
|
||||||
|
|
||||||
|
### Order of work
|
||||||
|
|
||||||
|
`restorePlan` orders the files so that few blobs are on disk at once:
|
||||||
|
|
||||||
|
- Downloaded blobs are kept decrypted in a cache directory.
|
||||||
|
- A file is restored once every blob it needs is in the cache.
|
||||||
|
- When no file is ready, the restore downloads the blobs for the file that
|
||||||
|
needs the fewest.
|
||||||
|
- `restoreSweeper` deletes a cached blob once no remaining file needs it.
|
||||||
|
|
||||||
|
A regular file is created with mode `0600` and gets its stored mode after its
|
||||||
|
content is written. A directory gets its stored mode, owner and modification
|
||||||
|
time after the whole restore.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
`Vaultik.VerifySnapshotWithOptions` checks a snapshot without the private key:
|
||||||
|
|
||||||
|
- It downloads the manifest.
|
||||||
|
- It checks that the snapshot's encrypted database is present.
|
||||||
|
- It checks that each listed blob is present with the listed size.
|
||||||
|
|
||||||
|
`Vaultik.RunDeepVerify` needs the private key. It does these steps:
|
||||||
|
|
||||||
|
- It downloads the manifest and the database. It checks the database as a
|
||||||
|
restore does.
|
||||||
|
- `verifyManifestAgainstDatabase` checks that both list the same blobs with the
|
||||||
|
same sizes.
|
||||||
|
- `verifyBlobExistenceFromDB` checks that every blob is present with the
|
||||||
|
recorded size.
|
||||||
|
- `verifyBlob` downloads each blob, checks the hash of each chunk in it, and
|
||||||
|
checks the hash that names the blob.
|
||||||
|
|
||||||
|
## Prune
|
||||||
|
|
||||||
|
`Vaultik.Prune` does three steps:
|
||||||
|
|
||||||
|
- `CleanupLocalSnapshots` drops from the local index each snapshot whose
|
||||||
|
manifest is gone from the destination.
|
||||||
|
- `PruneDatabase` removes unfinished snapshots and unused records from the
|
||||||
|
local index.
|
||||||
|
- `PruneBlobs` deletes unused data at the destination.
|
||||||
|
|
||||||
|
`PruneBlobs` reads every manifest at the destination. It then deletes two kinds
|
||||||
|
of object, once they are older than `prune_grace_period`:
|
||||||
|
|
||||||
|
- a blob that no manifest lists
|
||||||
|
- a directory under `metadata/` that has no manifest, and whose snapshot is not
|
||||||
|
in the local index
|
||||||
|
|
||||||
|
It deletes nothing when any manifest cannot be read.
|
||||||
|
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#pruning) explains why this is
|
||||||
|
safe, and where it is not.
|
||||||
|
|
||||||
|
## The destination
|
||||||
|
|
||||||
|
`storage.Storer` is the only way the code reaches the destination. It has these
|
||||||
|
methods:
|
||||||
|
|
||||||
|
- `Put` and `PutWithProgress` store an object.
|
||||||
|
- `Get` reads an object.
|
||||||
|
- `Stat` reports an object's size.
|
||||||
|
- `Delete` removes an object.
|
||||||
|
- `List` and `ListStream` list the objects under a prefix.
|
||||||
|
- `DeletePartialUploads` removes what an interrupted upload left behind.
|
||||||
|
- `Info` describes the destination.
|
||||||
|
|
||||||
|
`storage.NewStorer` picks the implementation from `storage_url`:
|
||||||
|
|
||||||
|
- `s3://` gives the S3 implementation.
|
||||||
|
- `file://` gives the local directory implementation.
|
||||||
|
- `rclone://` gives the rclone implementation.
|
||||||
|
|
||||||
|
With no `storage_url`, it builds the S3 implementation from the `s3` settings.
|
||||||
|
|
||||||
|
## The marker file and the destination binding
|
||||||
|
|
||||||
|
Two checks keep the local index and the destination in step.
|
||||||
|
|
||||||
|
- `checkDestinationMarker` looks for `vaultik-destination.txt`. It fails when
|
||||||
|
the file is reported missing while the local index holds snapshots.
|
||||||
|
- `EnsureStorageBinding` records the configured destination in the local index
|
||||||
|
on first use. Later it fails when the config names a different destination.
|
||||||
|
|
||||||
|
Every command that changes data calls `EnsureStorageBinding` first.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
- The local index is opened with one connection, so its queries run one at a
|
||||||
|
time.
|
||||||
|
- The index runs in SQLite's WAL mode. A second process can therefore read it
|
||||||
|
while a backup writes to it.
|
||||||
|
- `Packer` guards its state with a mutex.
|
||||||
|
- `Scanner` holds `packerMu` while a chunk is added to the packer and while a
|
||||||
|
blob is closed.
|
||||||
|
- `Repositories.WithTx` runs a function in one transaction. It commits when the
|
||||||
|
function succeeds and rolls back when it fails.
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Rules
|
|
||||||
|
|
||||||
Read the rules in AGENTS.md and follow them.
|
|
||||||
|
|
||||||
# Memory
|
|
||||||
|
|
||||||
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
|
||||||
commit messages (which it erroneously refers to as "attribution") is not
|
|
||||||
attribution, as I am the sole author of code created using Claude. It is
|
|
||||||
corporate advertising for Anthropic and is therefore completely
|
|
||||||
unacceptable in commit messages.
|
|
||||||
|
|
||||||
* Tests should always be run before committing code. No commits should be
|
|
||||||
made that do not pass tests.
|
|
||||||
|
|
||||||
* Code should always be formatted before committing. Do not commit
|
|
||||||
unformatted code.
|
|
||||||
|
|
||||||
* Code should always be linted before committing. Do not commit
|
|
||||||
unlinted code.
|
|
||||||
|
|
||||||
* The test suite is fast and local. When running tests, don't run
|
|
||||||
individual parts of the test suite, always run the whole thing by running
|
|
||||||
"make test".
|
|
||||||
|
|
||||||
* Do not stop working on a task until you have reached the definition of
|
|
||||||
done provided to you in the initial instruction. Don't do part or most of
|
|
||||||
the work, do all of the work until the criteria for done are met.
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
# vaultik: Design Document
|
|
||||||
|
|
||||||
`vaultik` is a secure backup tool written in Go. It performs
|
|
||||||
streaming backups using content-defined chunking, blob grouping, asymmetric
|
|
||||||
encryption, and object storage. The system is designed for environments
|
|
||||||
where the backup source host cannot store secrets and cannot retrieve or
|
|
||||||
decrypt any data from the destination.
|
|
||||||
|
|
||||||
The source host is **stateful**: it maintains a local SQLite index to detect
|
|
||||||
changes, deduplicate content, and track uploads across backup runs. All
|
|
||||||
remote storage is encrypted and append-only. Pruning of unreferenced data is
|
|
||||||
done from a trusted host with access to decryption keys, as even the
|
|
||||||
metadata indices are encrypted in the blob store.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
ANOTHER backup tool??
|
|
||||||
|
|
||||||
Other backup tools like `restic`, `borg`, and `duplicity` are designed for
|
|
||||||
environments where the source host can store secrets and has access to
|
|
||||||
decryption keys. I don't want to store backup decryption keys on my hosts,
|
|
||||||
only public keys for encryption.
|
|
||||||
|
|
||||||
My requirements are:
|
|
||||||
|
|
||||||
* open source
|
|
||||||
* no passphrases or private keys on the source host
|
|
||||||
* incremental
|
|
||||||
* compressed
|
|
||||||
* encrypted
|
|
||||||
* s3 compatible without an intermediate step or tool
|
|
||||||
|
|
||||||
Surprisingly, no existing tool meets these requirements, so I wrote `vaultik`.
|
|
||||||
|
|
||||||
## Design Goals
|
|
||||||
|
|
||||||
1. Backups must require only a public key on the source host.
|
|
||||||
2. No secrets or private keys may exist on the source system.
|
|
||||||
3. Obviously, restore must be possible using **only** the backup bucket and
|
|
||||||
a private key.
|
|
||||||
4. Prune must be possible, although this requires a private key so must be
|
|
||||||
done on different hosts.
|
|
||||||
5. All encryption is done using [`age`](https://github.com/FiloSottile/age)
|
|
||||||
(X25519, XChaCha20-Poly1305).
|
|
||||||
6. Compression uses `zstd` at a configurable level.
|
|
||||||
7. Files are chunked, and multiple chunks are packed into encrypted blobs.
|
|
||||||
This reduces the number of objects in the blob store for filesystems with
|
|
||||||
many small files.
|
|
||||||
9. All metadata (snapshots) is stored remotely as encrypted SQLite DBs.
|
|
||||||
10. If a snapshot metadata file exceeds a configured size threshold, it is
|
|
||||||
chunked into multiple encrypted `.age` parts, to support large
|
|
||||||
filesystems.
|
|
||||||
11. CLI interface is structured using `cobra`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## S3 Bucket Layout
|
|
||||||
|
|
||||||
S3 stores only four things:
|
|
||||||
|
|
||||||
1) Blobs: encrypted, compressed packs of file chunks.
|
|
||||||
2) Metadata: encrypted SQLite databases containing the current state of the
|
|
||||||
filesystem at the time of the snapshot.
|
|
||||||
3) Metadata hashes: encrypted hashes of the metadata SQLite databases.
|
|
||||||
4) Blob manifests: unencrypted compressed JSON files listing all blob hashes
|
|
||||||
referenced in the snapshot, enabling pruning without decryption.
|
|
||||||
|
|
||||||
```
|
|
||||||
s3://<bucket>/<prefix>/
|
|
||||||
├── blobs/
|
|
||||||
│ ├── <aa>/<bb>/<full_blob_hash>.zst.age
|
|
||||||
├── metadata/
|
|
||||||
│ ├── <snapshot_id>.sqlite.age
|
|
||||||
│ ├── <snapshot_id>.sqlite.00.age
|
|
||||||
│ ├── <snapshot_id>.sqlite.01.age
|
|
||||||
│ ├── <snapshot_id>.manifest.json.zst
|
|
||||||
```
|
|
||||||
|
|
||||||
To retrieve a given file, you would:
|
|
||||||
|
|
||||||
* fetch `metadata/<snapshot_id>.sqlite.age` or `metadata/<snapshot_id>.sqlite.{seq}.age`
|
|
||||||
* fetch `metadata/<snapshot_id>.hash.age`
|
|
||||||
* decrypt the metadata SQLite database using the private key and reconstruct
|
|
||||||
the full database file
|
|
||||||
* verify the hash of the decrypted database matches the decrypted hash
|
|
||||||
* query the database for the file in question
|
|
||||||
* determine all chunks for the file
|
|
||||||
* for each chunk, look up the metadata for all blobs in the db
|
|
||||||
* fetch each blob from `blobs/<aa>/<bb>/<blob_hash>.zst.age`
|
|
||||||
* decrypt each blob using the private key
|
|
||||||
* decompress each blob using `zstd`
|
|
||||||
* reconstruct the file from set of file chunks stored in the blobs
|
|
||||||
|
|
||||||
If clever, it may be possible to do this chunk by chunk without touching
|
|
||||||
disk (except for the output file) as each uncompressed blob should fit in
|
|
||||||
memory (<10GB).
|
|
||||||
|
|
||||||
### Path Rules
|
|
||||||
|
|
||||||
* `<snapshot_id>`: UTC timestamp in iso860 format, e.g. `2023-10-01T12:00:00Z`. These are lexicographically sortable.
|
|
||||||
* `blobs/<aa>/<bb>/...`: where `aa` and `bb` are the first 2 hex bytes of the blob hash.
|
|
||||||
|
|
||||||
### Blob Manifest Format
|
|
||||||
|
|
||||||
The `<snapshot_id>.manifest.json.zst` file is an unencrypted, compressed JSON file containing:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"snapshot_id": "2023-10-01T12:00:00Z",
|
|
||||||
"blob_hashes": [
|
|
||||||
"aa1234567890abcdef...",
|
|
||||||
"bb2345678901bcdef0...",
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This allows pruning operations to determine which blobs are referenced without requiring decryption keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Local SQLite Index Schema (source host)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE files (
|
|
||||||
path TEXT PRIMARY KEY,
|
|
||||||
mtime INTEGER NOT NULL,
|
|
||||||
size INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Maps files to their constituent chunks in sequence order
|
|
||||||
-- Used for reconstructing files from chunks during restore
|
|
||||||
CREATE TABLE file_chunks (
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
idx INTEGER NOT NULL,
|
|
||||||
chunk_hash TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (path, idx)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE chunks (
|
|
||||||
chunk_hash TEXT PRIMARY KEY,
|
|
||||||
sha256 TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE blobs (
|
|
||||||
blob_hash TEXT PRIMARY KEY,
|
|
||||||
final_hash TEXT NOT NULL,
|
|
||||||
created_ts INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE blob_chunks (
|
|
||||||
blob_hash TEXT NOT NULL,
|
|
||||||
chunk_hash TEXT NOT NULL,
|
|
||||||
offset INTEGER NOT NULL,
|
|
||||||
length INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (blob_hash, chunk_hash)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Reverse mapping: tracks which files contain a given chunk
|
|
||||||
-- Used for deduplication and tracking chunk usage across files
|
|
||||||
CREATE TABLE chunk_files (
|
|
||||||
chunk_hash TEXT NOT NULL,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
file_offset INTEGER NOT NULL,
|
|
||||||
length INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (chunk_hash, file_path)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE snapshots (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
hostname TEXT NOT NULL,
|
|
||||||
vaultik_version TEXT NOT NULL,
|
|
||||||
created_ts INTEGER NOT NULL,
|
|
||||||
file_count INTEGER NOT NULL,
|
|
||||||
chunk_count INTEGER NOT NULL,
|
|
||||||
blob_count INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Snapshot Metadata Schema (stored in S3)
|
|
||||||
|
|
||||||
Identical schema to the local index, filtered to live snapshot state. Stored
|
|
||||||
as a SQLite DB, compressed with `zstd`, encrypted with `age`. If larger than
|
|
||||||
a configured `chunk_size`, it is split and uploaded as:
|
|
||||||
|
|
||||||
```
|
|
||||||
metadata/<snapshot_id>.sqlite.00.age
|
|
||||||
metadata/<snapshot_id>.sqlite.01.age
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Data Flow
|
|
||||||
|
|
||||||
### 5.1 Backup
|
|
||||||
|
|
||||||
1. Load config
|
|
||||||
2. Open local SQLite index
|
|
||||||
3. Walk source directories:
|
|
||||||
|
|
||||||
* For each file:
|
|
||||||
|
|
||||||
* Check mtime and size in index
|
|
||||||
* If changed or new:
|
|
||||||
|
|
||||||
* Chunk file
|
|
||||||
* For each chunk:
|
|
||||||
|
|
||||||
* Hash with SHA256
|
|
||||||
* Check if already uploaded
|
|
||||||
* If not:
|
|
||||||
|
|
||||||
* Add chunk to blob packer
|
|
||||||
* Record file-chunk mapping in index
|
|
||||||
4. When blob reaches threshold size (e.g. 1GB):
|
|
||||||
|
|
||||||
* Compress with `zstd`
|
|
||||||
* Encrypt with `age`
|
|
||||||
* Upload to: `s3://<bucket>/<prefix>/blobs/<aa>/<bb>/<hash>.zst.age`
|
|
||||||
* Record blob-chunk layout in local index
|
|
||||||
5. Once all files are processed:
|
|
||||||
* Build snapshot SQLite DB from index delta
|
|
||||||
* Compress + encrypt
|
|
||||||
* If larger than `chunk_size`, split into parts
|
|
||||||
* Upload to:
|
|
||||||
`s3://<bucket>/<prefix>/metadata/<snapshot_id>.sqlite(.xx).age`
|
|
||||||
6. Create snapshot record in local index that lists:
|
|
||||||
* snapshot ID
|
|
||||||
* hostname
|
|
||||||
* vaultik version
|
|
||||||
* timestamp
|
|
||||||
* counts of files, chunks, and blobs
|
|
||||||
* list of all blobs referenced in the snapshot (some new, some old) for
|
|
||||||
efficient pruning later
|
|
||||||
7. Create snapshot database for upload
|
|
||||||
8. Calculate checksum of snapshot database
|
|
||||||
9. Compress, encrypt, split, and upload to S3
|
|
||||||
10. Encrypt the hash of the snapshot database to the backup age key
|
|
||||||
11. Upload the encrypted hash to S3 as `metadata/<snapshot_id>.hash.age`
|
|
||||||
12. Create blob manifest JSON listing all blob hashes referenced in snapshot
|
|
||||||
13. Compress manifest with zstd and upload as `metadata/<snapshot_id>.manifest.json.zst`
|
|
||||||
14. Optionally prune remote blobs that are no longer referenced in the
|
|
||||||
snapshot, based on local state db
|
|
||||||
|
|
||||||
### 5.2 Manual Prune
|
|
||||||
|
|
||||||
1. List all objects under `metadata/`
|
|
||||||
2. Determine the latest valid `snapshot_id` by timestamp
|
|
||||||
3. Download and decompress the latest `<snapshot_id>.manifest.json.zst`
|
|
||||||
4. Extract set of referenced blob hashes from manifest (no decryption needed)
|
|
||||||
5. List all blob objects under `blobs/`
|
|
||||||
6. For each blob:
|
|
||||||
* If the hash is not in the manifest:
|
|
||||||
* Issue `DeleteObject` to remove it
|
|
||||||
|
|
||||||
### 5.3 Verify
|
|
||||||
|
|
||||||
Verify runs on a host that has no state, but access to the bucket.
|
|
||||||
|
|
||||||
1. Fetch latest metadata snapshot files from S3
|
|
||||||
2. Fetch latest metadata db hash from S3
|
|
||||||
3. Decrypt the hash using the private key
|
|
||||||
4. Decrypt the metadata SQLite database chunks using the private key and
|
|
||||||
reassemble the snapshot db file
|
|
||||||
5. Calculate the SHA256 hash of the decrypted snapshot database
|
|
||||||
6. Verify the db file hash matches the decrypted hash
|
|
||||||
7. For each blob in the snapshot:
|
|
||||||
* Fetch the blob metadata from the snapshot db
|
|
||||||
* Ensure the blob exists in S3
|
|
||||||
* Check the S3 content hash matches the expected blob hash
|
|
||||||
* If not using --quick mode:
|
|
||||||
* Download and decrypt the blob
|
|
||||||
* Decompress and verify chunk hashes match metadata
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. CLI Commands
|
|
||||||
|
|
||||||
```
|
|
||||||
vaultik backup [--config <path>] [--cron] [--daemon] [--prune]
|
|
||||||
vaultik restore --bucket <bucket> --prefix <prefix> --snapshot <id> --target <dir>
|
|
||||||
vaultik prune --bucket <bucket> --prefix <prefix> [--dry-run]
|
|
||||||
vaultik verify --bucket <bucket> --prefix <prefix> [--snapshot <id>] [--quick]
|
|
||||||
vaultik fetch --bucket <bucket> --prefix <prefix> --snapshot <id> --file <path> --target <path>
|
|
||||||
vaultik snapshot list --bucket <bucket> --prefix <prefix> [--limit <n>]
|
|
||||||
vaultik snapshot rm --bucket <bucket> --prefix <prefix> --snapshot <id>
|
|
||||||
vaultik snapshot latest --bucket <bucket> --prefix <prefix>
|
|
||||||
```
|
|
||||||
|
|
||||||
* `VAULTIK_PRIVATE_KEY` is required for `restore`, `prune`, `verify`, and
|
|
||||||
`fetch` commands.
|
|
||||||
* It is passed via environment variable containing the age private key.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Function and Method Signatures
|
|
||||||
|
|
||||||
### 7.1 CLI
|
|
||||||
|
|
||||||
```go
|
|
||||||
func RootCmd() *cobra.Command
|
|
||||||
func backupCmd() *cobra.Command
|
|
||||||
func restoreCmd() *cobra.Command
|
|
||||||
func pruneCmd() *cobra.Command
|
|
||||||
func verifyCmd() *cobra.Command
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Configuration
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Config struct {
|
|
||||||
BackupPubKey string // age recipient
|
|
||||||
BackupInterval time.Duration // used in daemon mode, irrelevant for cron mode
|
|
||||||
BlobSizeLimit int64 // default 10GB
|
|
||||||
ChunkSize int64 // default 10MB
|
|
||||||
Exclude []string // list of regex of files to exclude from backup, absolute path
|
|
||||||
Hostname string
|
|
||||||
IndexPath string // path to local SQLite index db, default /var/lib/vaultik/index.db
|
|
||||||
MetadataPrefix string // S3 prefix for metadata, default "metadata/"
|
|
||||||
MinTimeBetweenRun time.Duration // minimum time between backup runs, default 1 hour - for daemon mode
|
|
||||||
S3 S3Config // S3 configuration
|
|
||||||
ScanInterval time.Duration // interval to full stat() scan source dirs, default 24h
|
|
||||||
SourceDirs []string // list of source directories to back up, absolute paths
|
|
||||||
}
|
|
||||||
|
|
||||||
type S3Config struct {
|
|
||||||
Endpoint string
|
|
||||||
Bucket string
|
|
||||||
Prefix string
|
|
||||||
AccessKeyID string
|
|
||||||
SecretAccessKey string
|
|
||||||
Region string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load(path string) (*Config, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 Index
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Index struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenIndex(path string) (*Index, error)
|
|
||||||
|
|
||||||
func (ix *Index) LookupFile(path string, mtime int64, size int64) ([]string, bool, error)
|
|
||||||
func (ix *Index) SaveFile(path string, mtime int64, size int64, chunkHashes []string) error
|
|
||||||
func (ix *Index) AddChunk(chunkHash string, size int64) error
|
|
||||||
func (ix *Index) MarkBlob(blobHash, finalHash string, created time.Time) error
|
|
||||||
func (ix *Index) MapChunkToBlob(blobHash, chunkHash string, offset, length int64) error
|
|
||||||
func (ix *Index) MapChunkToFile(chunkHash, filePath string, offset, length int64) error
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 Blob Packing
|
|
||||||
|
|
||||||
```go
|
|
||||||
type BlobWriter struct {
|
|
||||||
// internal buffer, current size, encrypted writer, etc
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewBlobWriter(...) *BlobWriter
|
|
||||||
func (bw *BlobWriter) AddChunk(chunk []byte, chunkHash string) error
|
|
||||||
func (bw *BlobWriter) Flush() (finalBlobHash string, err error)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.5 Metadata
|
|
||||||
|
|
||||||
```go
|
|
||||||
func BuildSnapshotMetadata(ix *Index, snapshotID string) (sqlitePath string, err error)
|
|
||||||
func EncryptAndUploadMetadata(path string, cfg *Config, snapshotID string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.6 Prune
|
|
||||||
|
|
||||||
```go
|
|
||||||
func RunPrune(bucket, prefix, privateKey string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
# Lint phase. The linter is invoked directly rather than through `make
|
||||||
|
# lint` or `script/lint`, which are themselves a docker build and would
|
||||||
|
# recurse into a daemon that does not exist in a build step.
|
||||||
|
# golangci/golangci-lint:v2.14.0, 2026-10-05
|
||||||
|
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# Without the time limit a stalled connection to the module proxy holds
|
||||||
|
# this step, and every build on the host sharing it, until the kernel
|
||||||
|
# drops the connection, which can take an hour. The limit is well above
|
||||||
|
# the three minutes a working download can take on a busy build host.
|
||||||
|
RUN for attempt in 1 2 3; do \
|
||||||
|
timeout 300 go mod download && exit 0; \
|
||||||
|
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||||
|
done; \
|
||||||
|
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||||
|
exit 1
|
||||||
|
COPY . .
|
||||||
|
# `golangci-lint run` silently ignores an unknown top-level key in
|
||||||
|
# .golangci.yml, such as a misspelt `linters:`; `config verify` fails on it.
|
||||||
|
RUN golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
|
# image ships and the alpine one does not.
|
||||||
|
# golang:1.26.1 (Debian trixie), 2026-10-05
|
||||||
|
FROM golang:1.26.1@sha256:cd78d88e00afadbedd272f977d375a6247455f3a4b1178f8ae8bbcb201743a8a AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# Limited and retried as in the lint phase above.
|
||||||
|
RUN for attempt in 1 2 3; do \
|
||||||
|
timeout 300 go mod download && exit 0; \
|
||||||
|
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||||
|
done; \
|
||||||
|
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||||
|
exit 1
|
||||||
|
COPY . .
|
||||||
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
|
# golang:1.26.1-alpine, 2026-03-17
|
||||||
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# Limited and retried as in the lint phase above.
|
||||||
|
RUN for attempt in 1 2 3; do \
|
||||||
|
timeout 300 go mod download && exit 0; \
|
||||||
|
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||||
|
done; \
|
||||||
|
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||||
|
exit 1
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# The VERSION build arg when one is given, otherwise
|
||||||
|
# `git describe --tags --always` on the .git in the build context. The
|
||||||
|
# commit and its date always come from that .git. With .git present, a
|
||||||
|
# version that is still empty, dev or unknown, or a commit or date that
|
||||||
|
# is unknown, fails the build: git is missing or could not read the
|
||||||
|
# checkout, as when .git is a file pointing outside the context. A
|
||||||
|
# context without .git, such as a source export, stamps "dev" and an
|
||||||
|
# "unknown" commit and date.
|
||||||
|
ARG VERSION
|
||||||
|
RUN VERSION="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
||||||
|
commit="$(git rev-parse HEAD || echo unknown)"; \
|
||||||
|
commit_date="$(git show -s --format=%cs HEAD || echo unknown)"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
if [ "$commit" = unknown ] || [ "$commit_date" = unknown ]; then \
|
||||||
|
echo "commit is '$commit' and its date '$commit_date'" \
|
||||||
|
"although .git is present" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi; \
|
||||||
|
globals=sneak.berlin/go/vaultik/internal/globals; \
|
||||||
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X ${globals}.Version=${VERSION} \
|
||||||
|
-X ${globals}.Commit=${commit} \
|
||||||
|
-X ${globals}.CommitDate=${commit_date}" \
|
||||||
|
-o /vaultik ./cmd/vaultik
|
||||||
|
|
||||||
|
# Runtime stage, and the last one: a plain `docker build .` builds this
|
||||||
|
# stage's chain and nothing else.
|
||||||
|
# alpine:3.21, 2026-02-25
|
||||||
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /vaultik /usr/local/bin/vaultik
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN adduser -D -H -s /sbin/nologin vaultik
|
||||||
|
|
||||||
|
USER vaultik
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/vaultik"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Jeffrey Paul sneak@sneak.berlin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,54 +1,130 @@
|
|||||||
.PHONY: test fmt lint build clean all
|
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks
|
||||||
|
|
||||||
|
# Where script/bootstrap installs Go when the host has none.
|
||||||
|
export PATH := $(PATH):$(CURDIR)/.tool/go/bin
|
||||||
|
|
||||||
|
# Version number, derived from git by script/version (`git describe
|
||||||
|
# --tags --always --dirty`). This used to be a hardcoded
|
||||||
|
# constant, which meant every local build claimed to be a release that
|
||||||
|
# had never been tagged.
|
||||||
|
VERSION := $(shell script/version)
|
||||||
|
|
||||||
|
# $(shell) discards exit status, so a script/version that is missing,
|
||||||
|
# non-executable or broken would otherwise leave VERSION empty and every
|
||||||
|
# binary built here would print "vaultik " with no version at all. A
|
||||||
|
# build that cannot determine what it is must not produce an artifact.
|
||||||
|
ifeq ($(strip $(VERSION)),)
|
||||||
|
$(error script/version produced no version string; a build that cannot \
|
||||||
|
determine its version will not be made. Check that script/version exists \
|
||||||
|
and is executable)
|
||||||
|
endif
|
||||||
|
|
||||||
# Build variables
|
# Build variables
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||||
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
GIT_COMMIT_DATE := $(shell git show -s --format=%cs HEAD 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
# Linker flags
|
# Linker flags
|
||||||
LDFLAGS := -X 'git.eeqj.de/sneak/vaultik/internal/globals.Version=$(VERSION)' \
|
LDFLAGS := -X 'sneak.berlin/go/vaultik/internal/globals.Version=$(VERSION)' \
|
||||||
-X 'git.eeqj.de/sneak/vaultik/internal/globals.Commit=$(COMMIT)'
|
-X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)' \
|
||||||
|
-X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(GIT_COMMIT_DATE)'
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: test
|
all: vaultik
|
||||||
|
|
||||||
# Run tests
|
# Install all development dependencies.
|
||||||
test: lint fmt-check
|
bootstrap:
|
||||||
go test -v ./...
|
@script/bootstrap
|
||||||
|
|
||||||
# Check if code is formatted
|
# Prepare a fresh clone: bootstrap plus pre-commit hook.
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
|
# Combined pre-commit/CI gate: tests, lint, format check.
|
||||||
|
check:
|
||||||
|
@script/check
|
||||||
|
|
||||||
|
# Run tests only, by building the test phase of the Dockerfile. This
|
||||||
|
# runs the ENTIRE suite -- there is no separate integration target and
|
||||||
|
# no build-tagged subset held back. In particular
|
||||||
|
# internal/vaultik/integration_test.go, which does full
|
||||||
|
# chunk -> pack -> encrypt -> upload -> restore round-trips, runs here.
|
||||||
|
# A `test-integration` target used to exist and was removed: no file in
|
||||||
|
# the repo carried a build tag, so `-tags=integration` selected nothing
|
||||||
|
# extra and the target was an exact duplicate of this one.
|
||||||
|
test:
|
||||||
|
@script/test
|
||||||
|
|
||||||
|
# Check if code is formatted (read-only).
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@if [ -n "$$(go fmt ./...)" ]; then \
|
@script/fmt-check
|
||||||
echo "Error: Code is not formatted. Run 'make fmt' to fix."; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Format code
|
# Format code.
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
@script/fmt
|
||||||
|
|
||||||
# Run linter
|
# Run linter only.
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
@script/lint
|
||||||
|
|
||||||
# Build binary
|
# Apply the linter's autofixes (rewrites files).
|
||||||
build:
|
lint-fix:
|
||||||
go build -ldflags "$(LDFLAGS)" -o vaultik ./cmd/vaultik
|
@script/lint-fix
|
||||||
|
|
||||||
# Clean build artifacts
|
# Build binary. `build` is the name the org convention reaches for and
|
||||||
|
# the one a caller checks the exit code of; `vaultik` is the file rule
|
||||||
|
# that does the work, so an unchanged tree still short-circuits.
|
||||||
|
#
|
||||||
|
# This alias is not decorative. `build` was listed in .PHONY with no
|
||||||
|
# rule, and a phony target with no prerequisites and no recipe is
|
||||||
|
# already satisfied: `make build` printed "Nothing to be done" and
|
||||||
|
# exited 0 without producing a binary (issue #110). Every name in
|
||||||
|
# .PHONY needs a rule for that reason; TestPhonyTargetsAllHaveRules in
|
||||||
|
# cmd/vaultik keeps it that way.
|
||||||
|
build: vaultik
|
||||||
|
|
||||||
|
vaultik: internal/*/*.go cmd/vaultik/*.go
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik
|
||||||
|
|
||||||
|
# Clean build artifacts.
|
||||||
clean:
|
clean:
|
||||||
rm -f vaultik
|
rm -f vaultik
|
||||||
go clean
|
go clean
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies. The linter is deliberately not installed here:
|
||||||
|
# script/lint lints by building the lint phase of the Dockerfile, whose
|
||||||
|
# FROM line is the single source of truth for the linter version. A
|
||||||
|
# second, separately pinned copy on PATH could drift from it and make a
|
||||||
|
# local `make lint` disagree with CI.
|
||||||
deps:
|
deps:
|
||||||
go mod download
|
go mod download
|
||||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
|
||||||
|
|
||||||
# Run tests with coverage
|
# Run tests with coverage, on the host. -count=1 because without it an
|
||||||
|
# unchanged package is served from Go's test result cache, and a
|
||||||
|
# coverage profile assembled from cached results describes a run that
|
||||||
|
# did not happen.
|
||||||
test-coverage:
|
test-coverage:
|
||||||
go test -v -coverprofile=coverage.out ./...
|
go test -v -count=1 -coverprofile=coverage.out ./...
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
# Run integration tests
|
local:
|
||||||
test-integration:
|
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug list 2>&1
|
||||||
go test -v -tags=integration ./...
|
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug create 2>&1
|
||||||
|
|
||||||
|
install: vaultik
|
||||||
|
cp ./vaultik $(HOME)/bin/
|
||||||
|
|
||||||
|
# Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser.
|
||||||
|
release:
|
||||||
|
@script/release
|
||||||
|
|
||||||
|
# Dry-run a release build without publishing or tagging.
|
||||||
|
release-snapshot:
|
||||||
|
@script/release-snapshot
|
||||||
|
|
||||||
|
# Build Docker image.
|
||||||
|
docker:
|
||||||
|
@script/docker
|
||||||
|
|
||||||
|
# Install pre-commit hook.
|
||||||
|
hooks:
|
||||||
|
@script/install-precommit
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
---
|
||||||
|
title: Repository Policies
|
||||||
|
last_modified: 2026-10-04
|
||||||
|
---
|
||||||
|
|
||||||
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
|
style conventions are in separate documents:
|
||||||
|
|
||||||
|
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||||
|
(general, bash, Docker)
|
||||||
|
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||||
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Cross-project documentation (such as this file) must include
|
||||||
|
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||||
|
with the authoritative source as policies evolve.
|
||||||
|
|
||||||
|
- **ALL external references must be pinned by cryptographic hash.** This
|
||||||
|
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||||
|
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||||
|
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||||
|
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||||
|
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||||
|
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||||
|
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||||
|
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||||
|
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||||
|
install scripts are arbitrary remote code execution. This is the single most
|
||||||
|
important rule in this document. Double-check every external reference in
|
||||||
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an executable
|
||||||
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||||
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||||
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||||
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||||
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||||
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||||
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||||
|
for development after a fresh clone: runs `bootstrap`, then
|
||||||
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||||
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
|
installed node if present; otherwise it installs a PINNED node version via
|
||||||
|
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||||
|
release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
|
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||||
|
docker and git then gets through `script/check`. Four further scripts are our
|
||||||
|
own extensions to the standard: `script/check` runs `script/test`,
|
||||||
|
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||||
|
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||||
|
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||||
|
`script/projectname` (literally that filename) simply outputs the project's
|
||||||
|
name. Scripts that need the name call `script/projectname` — e.g.
|
||||||
|
`script/docker` assembles its image tag from it — so those scripts stay
|
||||||
|
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||||
|
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||||
|
the hook itself. Model scripts are at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
|
README requirements below).
|
||||||
|
|
||||||
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
|
source of truth for how these operations are run.
|
||||||
|
|
||||||
|
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||||
|
the required targets above, it should have targets for every common operation:
|
||||||
|
running a local development server (`make run`, `make dev`), re-initializing
|
||||||
|
or migrating the database (`make db-reset`, `make migrate`), building
|
||||||
|
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||||
|
developer would do regularly. If someone checks out the repo and types
|
||||||
|
`make<tab>`, they should see every meaningful operation available. A new
|
||||||
|
contributor should be able to understand the entire development workflow by
|
||||||
|
reading the Makefile.
|
||||||
|
|
||||||
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
|
The gate phases and the build stage start from their pinned base images and
|
||||||
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
|
environment stage installs development prerequisites by running
|
||||||
|
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||||
|
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||||
|
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||||
|
|
||||||
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
|
and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||||
|
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||||
|
```
|
||||||
|
|
||||||
|
**A stage that is not the last one in the file is built only when the final
|
||||||
|
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||||
|
two gates are always invoked by name here, and why the final stage carries a
|
||||||
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||||
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
|
and tested nothing.
|
||||||
|
|
||||||
|
**Every `docker build` in `script/` is tagged**, here and in
|
||||||
|
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||||
|
image behind on every invocation, on every developer host and every CI
|
||||||
|
runner; a tagged one replaces the previous image.
|
||||||
|
|
||||||
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
|
themselves a `docker build` and would recurse into a daemon that does not
|
||||||
|
exist in a build step. Formatting is the exception and stays on the host:
|
||||||
|
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||||
|
read-only twin.
|
||||||
|
|
||||||
|
**No lint verdict may come from a host invocation of the linter.** On a
|
||||||
|
shared host golangci-lint reads a result cache keyed on file content rather
|
||||||
|
than location, so a second checkout of the same content is served the first
|
||||||
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
|
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||||
|
a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||||
|
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||||
|
the check `RUN` is served from cache, nothing executes, and the build still
|
||||||
|
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||||
|
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||||
|
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||||
|
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||||
|
not evidence that anything ran: a sub-second build reporting success is a
|
||||||
|
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||||
|
and friends destroy a build cache shared with every other build on the host.
|
||||||
|
When a check is added or changed, prove it works by planting a defect it must
|
||||||
|
catch and watching the run fail on it, then revert the defect. A green run
|
||||||
|
alone shows neither that the check ran nor that it covers what it should.
|
||||||
|
|
||||||
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Lint phase
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
|
# image ships and the alpine one does not.
|
||||||
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# The VERSION build arg when one is given, otherwise
|
||||||
|
# `git describe --tags --always` on the .git in the build context. With
|
||||||
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
|
# build: git is missing or could not read the checkout.
|
||||||
|
ARG VERSION
|
||||||
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi; \
|
||||||
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage, and the last one
|
||||||
|
FROM alpine@sha256:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
|
both Go and the linter), so nothing needs installing.
|
||||||
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
|
lines a red gate would not fail the build.
|
||||||
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
|
and nothing else.
|
||||||
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
|
create placeholder files so the embed directives resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||||
|
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||||
|
submodule keeping its own `.git` directory. `git describe` does not need
|
||||||
|
them, and each can hold a credential: a password in a remote URL, or the
|
||||||
|
token the CI checkout step stores there. A submodule whose name has a
|
||||||
|
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||||
|
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||||
|
then fails the build: give it a name without that segment
|
||||||
|
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||||
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
|
||||||
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
|
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||||
|
successful run means every check passed; a bare `docker build .` does not
|
||||||
|
carry the same guarantee, because its gate phases may come from the cache. The
|
||||||
|
image build is uncached and so runs the gate phases a second time. That is the
|
||||||
|
price of the rule above, and it is worth paying: the image that ships is built
|
||||||
|
from a run of its own gates rather than from a cache entry. A separate
|
||||||
|
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||||
|
checked by review: to try a change to it, add the feature branch to that list
|
||||||
|
and push, then remove the branch from the list again before merging. Keep any
|
||||||
|
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||||
|
from the feature branch publishes nothing.
|
||||||
|
|
||||||
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
|
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||||
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||||
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||||
|
that shims to it.
|
||||||
|
|
||||||
|
- All repos with software must have tests that run via the platform-standard
|
||||||
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
|
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||||
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
|
`make test` to be a no-op.
|
||||||
|
|
||||||
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||||
|
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||||
|
20 and 60 seconds is still green, but the overage must be filed as an
|
||||||
|
improvement bug against that repo. Add a 90-second timeout to the test
|
||||||
|
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||||
|
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||||
|
one.
|
||||||
|
|
||||||
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
|
detail on failure (every test case, every assertion). The command lives in the
|
||||||
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||||
|
Makefile form below is the same pattern for any repo-local invocation:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so neither run can report a stored pass in place of running the
|
||||||
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
|
and no recompilation.
|
||||||
|
|
||||||
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
|
without running them. That matters on a developer's machine, where this
|
||||||
|
target runs and the directory lasts from one run to the next. The `test`
|
||||||
|
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||||
|
result for this repo's tests and nothing before its `go test` step runs a
|
||||||
|
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||||
|
step run on an unchanged tree.
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||||
|
already proved the tests are broken, so the build must not pass even if a
|
||||||
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
|
for diagnostic output.
|
||||||
|
|
||||||
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
|
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||||
|
directories.
|
||||||
|
|
||||||
|
- `main` must always pass `make check`, no exceptions.
|
||||||
|
|
||||||
|
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||||
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
|
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||||
|
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
|
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||||
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||||
|
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||||
|
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||||
|
therefore excludes only the copies at the repository root, while `config/.env`
|
||||||
|
and `certs/server.key` still reach the context and can land in an image layer
|
||||||
|
— which is more dangerous than a short file with no secret patterns at all,
|
||||||
|
because it reads as solved and stops anyone looking. Give every
|
||||||
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
|
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||||
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||||
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||||
|
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||||
|
also catches something the build needs, re-include it with a negation
|
||||||
|
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||||
|
other file it covers. Fetch the standard `.dockerignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
|
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||||
|
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||||
|
additional checkout of the repo — so under `COPY . .` the build context
|
||||||
|
inflates by a multiple of the repo and another session's unreviewed work can
|
||||||
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||||
|
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||||
|
prefix, because the prefixed form would also delete any nested directory of
|
||||||
|
that name from the build. Anchoring carries a known gap that the canonical
|
||||||
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||||
|
file and not the tracker: the directory is created in the agent's working
|
||||||
|
directory, so a repo running agents in subdirectories still ships
|
||||||
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
|
- **A plain `docker build .` of a clone stamps the version that
|
||||||
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||||
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||||
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||||
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||||
|
the version they compute on the host; it takes precedence. They do this
|
||||||
|
byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Own line: a failing command substitution inside an argument does not
|
||||||
|
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$(script/projectname)" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||||
|
fallback is applied — a live check that fires on a build from an export with
|
||||||
|
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||||
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
|
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||||
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||||
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
|
the scripts stay byte-identical. One consequence for CI: the standard
|
||||||
|
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||||
|
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
|
transfers only the delta from the previous build.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
|
- Never force-push to `main`.
|
||||||
|
|
||||||
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
|
feature branch.
|
||||||
|
|
||||||
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||||
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||||
|
because it cannot be written once for every repo: the `deny` list of the
|
||||||
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
|
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||||
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||||
|
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||||
|
disagree about what the tool even is. The canonical form:
|
||||||
|
- compares the installed version against the pin over the **whole** version
|
||||||
|
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||||
|
running `2.12.2-rc1` and skips the install;
|
||||||
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
|
skipped one;
|
||||||
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||||
|
then through `PATH`, not through the directory the installer wrote to —
|
||||||
|
and fails naming the resolved path, since an install that a shadowing
|
||||||
|
binary hides succeeds while changing nothing any caller sees;
|
||||||
|
- is actually called, and prints the version on both success paths: a
|
||||||
|
function defined and never invoked has the same exit status and the same
|
||||||
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
|
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||||
|
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||||
|
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||||
|
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||||
|
|
||||||
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
|
with the version and date (YYYY-MM-DD).
|
||||||
|
|
||||||
|
- Use `yarn`, not `npm`.
|
||||||
|
|
||||||
|
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||||
|
|
||||||
|
- Simple projects should be configured with environment variables.
|
||||||
|
|
||||||
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
|
- `README.md` is the primary documentation. Required sections:
|
||||||
|
- **Description**: First line must include the project name, purpose,
|
||||||
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
|
- **Rationale**: Why does this exist?
|
||||||
|
- **Design**: How is the program structured?
|
||||||
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
|
the todo list in the README so a new agent can pick up where the last one
|
||||||
|
left off.
|
||||||
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||||
|
`LICENSE` file in the repo root and a License section in the README.
|
||||||
|
- **Author**: [@sneak](https://sneak.berlin).
|
||||||
|
|
||||||
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
|
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||||
|
committing.
|
||||||
|
|
||||||
|
- Use SemVer.
|
||||||
|
|
||||||
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
|
the binary.
|
||||||
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
|
settings.
|
||||||
|
|
||||||
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
|
Canonical subdirectory names:
|
||||||
|
- `bin/` — executable scripts and tools
|
||||||
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
`cmd/`
|
||||||
|
- `configs/` — configuration templates and examples
|
||||||
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
|
- `internal/` — Go internal packages
|
||||||
|
- `internal/db/migrations/` — database migrations
|
||||||
|
- `pkg/` — Go library packages
|
||||||
|
- `share/` — systemd units, data files
|
||||||
|
- `static/` — static assets (images, fonts, etc.)
|
||||||
|
- `web/` — web frontend source
|
||||||
|
|
||||||
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||||
|
templates. Fetch them from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||||
|
|
||||||
|
- New repos must contain at minimum:
|
||||||
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
|
- `Dockerfile`, `.dockerignore`
|
||||||
|
- `.gitea/workflows/check.yml`
|
||||||
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
|
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||||
|
is never committed under a file or directory named after one agent tool, such
|
||||||
|
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDockerfileBoundsEveryModuleDownload fails unless every
|
||||||
|
// `go mod download` in the Dockerfile runs under a time limit, is tried
|
||||||
|
// a fixed number of times, and then fails the build with a message that
|
||||||
|
// names the module download (issue #351). Like the version tests, this
|
||||||
|
// parses the Dockerfile; that a stalled download really is cut off and
|
||||||
|
// retried was checked by hand.
|
||||||
|
func TestDockerfileBoundsEveryModuleDownload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var downloads []string
|
||||||
|
|
||||||
|
for _, instruction := range instructions(t, productDockerfile) {
|
||||||
|
if strings.Contains(instruction, "go mod download") {
|
||||||
|
downloads = append(downloads, instruction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotEmpty(t, downloads, "%s must download modules",
|
||||||
|
productDockerfile)
|
||||||
|
|
||||||
|
for _, download := range downloads {
|
||||||
|
assert.Contains(t, download, "timeout 300 go mod download",
|
||||||
|
"%s: a module download must have a time limit: %s",
|
||||||
|
productDockerfile, download)
|
||||||
|
assert.Contains(t, download, "for attempt in 1 2 3",
|
||||||
|
"%s: a module download must be retried: %s",
|
||||||
|
productDockerfile, download)
|
||||||
|
assert.Contains(t, download, "module download failed",
|
||||||
|
"%s: a module download that never succeeds must say so: %s",
|
||||||
|
productDockerfile, download)
|
||||||
|
assert.True(t, strings.HasSuffix(download, "exit 1"),
|
||||||
|
"%s: a module download that never succeeds must fail the"+
|
||||||
|
" build: %s", productDockerfile, download)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file guards the version stamping of the product image (issue
|
||||||
|
// #75). The failure it protects against is silent: the image still
|
||||||
|
// builds and runs, but `vaultik version` inside it reports "commit:
|
||||||
|
// unknown" or a version of "dev", so an operator cannot tell which
|
||||||
|
// source produced a given backup. The build takes the version as a
|
||||||
|
// build arg, which script/docker computes on the host, and otherwise
|
||||||
|
// derives it from the .git in its context; the commit and its date
|
||||||
|
// always come from that .git.
|
||||||
|
//
|
||||||
|
// These are parses of the committed files, because shelling out to
|
||||||
|
// docker would nest a build inside `make test`. That `vaultik version`
|
||||||
|
// in the built image really prints the host's version is verified by
|
||||||
|
// hand.
|
||||||
|
|
||||||
|
// The files under guard, relative to the repository root.
|
||||||
|
const (
|
||||||
|
productDockerfile = "Dockerfile"
|
||||||
|
dockerScript = "script/docker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProductDockerfileTakesVersionAsBuildArg fails unless the build
|
||||||
|
// declares ARG VERSION, with no default, and stamps it into the binary
|
||||||
|
// whenever it is given, ahead of the value derived in the container.
|
||||||
|
func TestProductDockerfileTakesVersionAsBuildArg(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
found := instructions(t, productDockerfile)
|
||||||
|
|
||||||
|
require.Contains(t, found, "ARG VERSION",
|
||||||
|
"%s must declare `ARG VERSION`, with no default, so the host can"+
|
||||||
|
" pass it in", productDockerfile)
|
||||||
|
|
||||||
|
assertLdflagReferences(t, found, "VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProductDockerfileDerivesVersionFromGit fails unless a build given
|
||||||
|
// no VERSION, such as a plain `docker build .` of a clone, takes it from
|
||||||
|
// `git describe` of the .git in its context, stamps "dev" when the
|
||||||
|
// context has no .git, and fails rather than stamp "dev" when that .git
|
||||||
|
// yields no version. The commit and its date come from the same .git,
|
||||||
|
// and the build fails rather than stamp them "unknown" when it is
|
||||||
|
// present.
|
||||||
|
func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
found := instructions(t, productDockerfile)
|
||||||
|
|
||||||
|
buildAt := indexContaining(found, "go build")
|
||||||
|
require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile)
|
||||||
|
|
||||||
|
assert.Contains(t, found[buildAt], "git describe --tags --always || echo dev",
|
||||||
|
"%s must derive the version from git when no VERSION is given,"+
|
||||||
|
" and stamp dev when the context has no .git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt], "[ -e .git ]",
|
||||||
|
"%s must fail when the context carries .git but yields no version",
|
||||||
|
productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt], "git rev-parse HEAD",
|
||||||
|
"%s must stamp the commit from git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt], "git show -s --format=%cs HEAD",
|
||||||
|
"%s must stamp the commit date from git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt],
|
||||||
|
`[ "$commit" = unknown ] || [ "$commit_date" = unknown ]`,
|
||||||
|
"%s must fail when the context carries .git but yields no commit"+
|
||||||
|
" or date", productDockerfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
||||||
|
// passes the version it derives where .git exists as a build arg.
|
||||||
|
func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
script := readRepoFile(t, dockerScript)
|
||||||
|
|
||||||
|
assert.Contains(t, script, "--build-arg VERSION=",
|
||||||
|
"%s must pass --build-arg VERSION to the build", dockerScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertLdflagReferences fails unless the build instruction uses the
|
||||||
|
// named ARG whenever it is given (a ${arg:- reference), so a value
|
||||||
|
// passed in is not overridden by one derived inside the container.
|
||||||
|
func assertLdflagReferences(t *testing.T, found []string, arg string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, instruction := range found {
|
||||||
|
if strings.HasPrefix(instruction, "RUN ") &&
|
||||||
|
strings.Contains(instruction, "go build") &&
|
||||||
|
strings.Contains(instruction, "${"+arg+":-") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Fail(t, "version arg is declared but never stamped",
|
||||||
|
"the go build in %s must use ${%s:-...}, or the arg is passed and"+
|
||||||
|
" discarded", productDockerfile, arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// instructions returns the Dockerfile's instructions, one per element,
|
||||||
|
// with comments and blank lines dropped and continuation lines joined,
|
||||||
|
// so a multi-line RUN is one string.
|
||||||
|
func instructions(t *testing.T, name string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
continued string
|
||||||
|
isContinued bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(readRepoFile(t, name), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !isContinued && (trimmed == "" || strings.HasPrefix(trimmed, "#")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isContinued = strings.HasSuffix(trimmed, `\`)
|
||||||
|
continued += strings.TrimSuffix(trimmed, `\`)
|
||||||
|
|
||||||
|
if isContinued {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, strings.Join(strings.Fields(continued), " "))
|
||||||
|
continued = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexContaining returns the position of the first instruction
|
||||||
|
// containing want; -1 if there is none.
|
||||||
|
func indexContaining(found []string, want string) int {
|
||||||
|
for i, instruction := range found {
|
||||||
|
if strings.Contains(instruction, want) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
+53
-2
@@ -1,9 +1,60 @@
|
|||||||
|
// Package main is the vaultik command-line entry point.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.eeqj.de/sneak/vaultik/internal/cli"
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"runtime/pprof"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cli.CLIEntry()
|
os.Exit(run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// run sets up optional profiling, runs the CLI, and returns the process
|
||||||
|
// exit code. os.Exit lives in main so it fires only after run's deferred
|
||||||
|
// profile writers have flushed. cli.Entry returns a status code rather
|
||||||
|
// than calling os.Exit itself: an os.Exit from inside it would skip
|
||||||
|
// these defers and truncate the profile of a failing command -- exactly
|
||||||
|
// the command one most often wants to profile.
|
||||||
|
func run() int {
|
||||||
|
// CPU profiling: set VAULTIK_CPUPROFILE=/path/to/cpu.prof
|
||||||
|
if cpuProfile := os.Getenv("VAULTIK_CPUPROFILE"); cpuProfile != "" {
|
||||||
|
f, err := os.Create(cpuProfile) //nolint:gosec // G304: operator-set path
|
||||||
|
if err != nil {
|
||||||
|
panic("could not create CPU profile: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
err = pprof.StartCPUProfile(f)
|
||||||
|
if err != nil {
|
||||||
|
panic("could not start CPU profile: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
defer pprof.StopCPUProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory profiling: set VAULTIK_MEMPROFILE=/path/to/mem.prof
|
||||||
|
if memProfile := os.Getenv("VAULTIK_MEMPROFILE"); memProfile != "" {
|
||||||
|
defer func() {
|
||||||
|
f, err := os.Create(memProfile) //nolint:gosec // G304: operator-set path
|
||||||
|
if err != nil {
|
||||||
|
panic("could not create memory profile: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
runtime.GC() // get up-to-date statistics
|
||||||
|
|
||||||
|
err = pprof.WriteHeapProfile(f)
|
||||||
|
if err != nil {
|
||||||
|
panic("could not write memory profile: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.Entry()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file guards the Makefile that builds this program, which is why
|
||||||
|
// it lives beside it rather than in a package of its own.
|
||||||
|
//
|
||||||
|
// Issue #110: `build` was listed in .PHONY with no `build:` rule
|
||||||
|
// anywhere in the file. That combination is silently successful — make
|
||||||
|
// considers a phony target with no prerequisites and no recipe already
|
||||||
|
// satisfied, so `rm -f vaultik && make build` printed "Nothing to be
|
||||||
|
// done for 'build'" and exited 0 with no binary produced. Declaring the
|
||||||
|
// name phony is precisely what converts the "No rule to make target"
|
||||||
|
// error into a green.
|
||||||
|
//
|
||||||
|
// The guard is a parse of the Makefile rather than an invocation of
|
||||||
|
// make. `make test` is what runs these tests, so shelling back into
|
||||||
|
// `make build` here would nest a build inside the test run and drop a
|
||||||
|
// binary into the tree as a side effect of testing. The one property a
|
||||||
|
// parse cannot establish — that the recipe still fails when the build
|
||||||
|
// fails — is not testable from inside the build either; it is verified
|
||||||
|
// by hand against a deliberately broken tree.
|
||||||
|
|
||||||
|
// phonyDirective introduces the list of phony target names.
|
||||||
|
const phonyDirective = ".PHONY:"
|
||||||
|
|
||||||
|
// ruleLine matches a rule's target list: a target starts in column
|
||||||
|
// zero, so recipe lines (tab-indented) and the continuation lines of a
|
||||||
|
// variable assignment (space-indented) are excluded by construction.
|
||||||
|
//
|
||||||
|
// The trailing (?:[^=]|$) rejects `:=` assignments such as
|
||||||
|
// `VERSION := $(shell script/version)`, which are not rules. Directives
|
||||||
|
// and function calls (`.PHONY:`, `ifeq`, `$(error ...)`) do not match
|
||||||
|
// because a target here must begin with a letter, digit or underscore.
|
||||||
|
var ruleLine = regexp.MustCompile(`^([A-Za-z0-9_][A-Za-z0-9_./ -]*):(?:[^=]|$)`)
|
||||||
|
|
||||||
|
// TestPhonyTargetsAllHaveRules fails on any name in .PHONY that has no
|
||||||
|
// rule in the Makefile. Such a name is not a build target at all: it is
|
||||||
|
// a command that reports success without doing anything, which is worse
|
||||||
|
// than one that does not exist, because a caller checking the exit code
|
||||||
|
// cannot tell the difference.
|
||||||
|
func TestPhonyTargetsAllHaveRules(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
makefile := readMakefile(t)
|
||||||
|
|
||||||
|
phony := phonyTargets(makefile)
|
||||||
|
require.NotEmpty(t, phony, "no .PHONY names found; the parser is broken")
|
||||||
|
|
||||||
|
rules := declaredRules(makefile)
|
||||||
|
|
||||||
|
// Sanity check on the rule parser before trusting its verdict: a
|
||||||
|
// parser that found nothing would pass this test by accident.
|
||||||
|
require.Contains(t, rules, "vaultik",
|
||||||
|
"the file rule that builds the binary must be recognized")
|
||||||
|
|
||||||
|
for _, target := range phony {
|
||||||
|
assert.Contains(t, rules, target,
|
||||||
|
"`.PHONY` lists %q but the Makefile declares no %q rule, so "+
|
||||||
|
"`make %s` exits 0 without doing anything", target, target, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTargetBuildsTheBinary pins the specific shape of issue #110:
|
||||||
|
// `make build` has to reach the rule that produces the binary. The test
|
||||||
|
// above would also pass if `build:` were given an empty recipe of its
|
||||||
|
// own, which would be the same silent success under a different
|
||||||
|
// spelling.
|
||||||
|
func TestBuildTargetBuildsTheBinary(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
prerequisites := rulePrerequisites(readMakefile(t), "build")
|
||||||
|
require.NotNil(t, prerequisites, "the Makefile declares no `build` rule")
|
||||||
|
|
||||||
|
assert.Contains(t, prerequisites, "vaultik",
|
||||||
|
"`make build` must depend on the rule that builds the binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMakefile returns the contents of the repository's Makefile.
|
||||||
|
func readMakefile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return readRepoFile(t, "Makefile")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRepoFile reads a file by its path relative to the repository
|
||||||
|
// root.
|
||||||
|
func readRepoFile(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
//nolint:gosec // G304: the path is a constant relative to this repo
|
||||||
|
contents, err := os.ReadFile(filepath.Join(repoRoot(t), name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return string(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoRoot returns the repository root. The test binary runs with its
|
||||||
|
// package directory as the working directory, so the root is found by
|
||||||
|
// walking up until the module file appears.
|
||||||
|
func repoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "go.mod"))
|
||||||
|
if err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
require.NotEqual(t, dir, parent,
|
||||||
|
"walked to the filesystem root without finding a go.mod")
|
||||||
|
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// phonyTargets returns every name declared phony, across all .PHONY
|
||||||
|
// lines.
|
||||||
|
func phonyTargets(makefile string) []string {
|
||||||
|
var targets []string
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(makefile, "\n") {
|
||||||
|
if !strings.HasPrefix(line, phonyDirective) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = append(targets,
|
||||||
|
strings.Fields(strings.TrimPrefix(line, phonyDirective))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// declaredRules returns the set of target names that have a rule.
|
||||||
|
func declaredRules(makefile string) map[string]bool {
|
||||||
|
rules := make(map[string]bool)
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(makefile, "\n") {
|
||||||
|
match := ruleLine.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// One rule may name several targets: `a b: prereq`.
|
||||||
|
for target := range strings.FieldsSeq(match[1]) {
|
||||||
|
rules[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// rulePrerequisites returns the prerequisites of the named rule, or nil
|
||||||
|
// if no such rule exists. A rule with none returns an empty slice, so
|
||||||
|
// "declared with nothing to do" is distinguishable from "not declared".
|
||||||
|
func rulePrerequisites(makefile, target string) []string {
|
||||||
|
for line := range strings.SplitSeq(makefile, "\n") {
|
||||||
|
match := ruleLine.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(strings.Fields(match[1]), target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after, _ := strings.Cut(line, ":")
|
||||||
|
|
||||||
|
return append([]string{}, strings.Fields(after)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# vaultik configuration file example
|
||||||
|
# This file shows every configuration option. The values are examples, not
|
||||||
|
# defaults; where an option has a default, its comment gives it.
|
||||||
|
# Copy this file and change the values you need
|
||||||
|
|
||||||
|
# Age recipient public keys for encryption
|
||||||
|
# Backups are encrypted to these public keys. snapshot create needs at least
|
||||||
|
# one; listing, verifying and restoring do not
|
||||||
|
# Generate a key pair, and show its public key, with:
|
||||||
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
|
# Keep the private key file: restoring needs it. Do not pipe age-keygen
|
||||||
|
# straight into grep, which would discard the private key
|
||||||
|
age_recipients:
|
||||||
|
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
||||||
|
|
||||||
|
# Age private key for decryption
|
||||||
|
# Only snapshot restore and snapshot verify --deep need it. The
|
||||||
|
# VAULTIK_AGE_SECRET_KEY environment variable overrides this setting
|
||||||
|
# Do not set it on a machine you back up: prefer the environment variable,
|
||||||
|
# supplied only on the machine you restore on
|
||||||
|
# Default: not set
|
||||||
|
#age_secret_key: YOUR_AGE_PRIVATE_KEY
|
||||||
|
|
||||||
|
# Named snapshots - each snapshot can contain multiple paths
|
||||||
|
# Each snapshot gets its own ID and can have snapshot-specific excludes
|
||||||
|
# Required: at least one snapshot with at least one path. Only the config
|
||||||
|
# commands, version and completion run without one
|
||||||
|
snapshots:
|
||||||
|
testing:
|
||||||
|
paths:
|
||||||
|
- ~/dev/vaultik
|
||||||
|
apps:
|
||||||
|
paths:
|
||||||
|
- /Applications
|
||||||
|
exclude:
|
||||||
|
- "/App Store.app"
|
||||||
|
- "/Apps.app"
|
||||||
|
- "/Automator.app"
|
||||||
|
- "/Books.app"
|
||||||
|
- "/Calculator.app"
|
||||||
|
- "/Calendar.app"
|
||||||
|
- "/Chess.app"
|
||||||
|
- "/Clock.app"
|
||||||
|
- "/Contacts.app"
|
||||||
|
- "/Dictionary.app"
|
||||||
|
- "/FaceTime.app"
|
||||||
|
- "/FindMy.app"
|
||||||
|
- "/Font Book.app"
|
||||||
|
- "/Freeform.app"
|
||||||
|
- "/Games.app"
|
||||||
|
- "/GarageBand.app"
|
||||||
|
- "/Home.app"
|
||||||
|
- "/Image Capture.app"
|
||||||
|
- "/Image Playground.app"
|
||||||
|
- "/Journal.app"
|
||||||
|
- "/Keynote.app"
|
||||||
|
- "/Mail.app"
|
||||||
|
- "/Maps.app"
|
||||||
|
- "/Messages.app"
|
||||||
|
- "/Mission Control.app"
|
||||||
|
- "/Music.app"
|
||||||
|
- "/News.app"
|
||||||
|
- "/Notes.app"
|
||||||
|
- "/Numbers.app"
|
||||||
|
- "/Pages.app"
|
||||||
|
- "/Passwords.app"
|
||||||
|
- "/Phone.app"
|
||||||
|
- "/Photo Booth.app"
|
||||||
|
- "/Photos.app"
|
||||||
|
- "/Podcasts.app"
|
||||||
|
- "/Preview.app"
|
||||||
|
- "/QuickTime Player.app"
|
||||||
|
- "/Reminders.app"
|
||||||
|
- "/Safari.app"
|
||||||
|
- "/Shortcuts.app"
|
||||||
|
- "/Siri.app"
|
||||||
|
- "/Stickies.app"
|
||||||
|
- "/Stocks.app"
|
||||||
|
- "/System Settings.app"
|
||||||
|
- "/TV.app"
|
||||||
|
- "/TextEdit.app"
|
||||||
|
- "/Time Machine.app"
|
||||||
|
- "/Tips.app"
|
||||||
|
- "/Utilities/Activity Monitor.app"
|
||||||
|
- "/Utilities/AirPort Utility.app"
|
||||||
|
- "/Utilities/Audio MIDI Setup.app"
|
||||||
|
- "/Utilities/Bluetooth File Exchange.app"
|
||||||
|
- "/Utilities/Boot Camp Assistant.app"
|
||||||
|
- "/Utilities/ColorSync Utility.app"
|
||||||
|
- "/Utilities/Console.app"
|
||||||
|
- "/Utilities/Digital Color Meter.app"
|
||||||
|
- "/Utilities/Disk Utility.app"
|
||||||
|
- "/Utilities/Grapher.app"
|
||||||
|
- "/Utilities/Magnifier.app"
|
||||||
|
- "/Utilities/Migration Assistant.app"
|
||||||
|
- "/Utilities/Print Center.app"
|
||||||
|
- "/Utilities/Screen Sharing.app"
|
||||||
|
- "/Utilities/Screenshot.app"
|
||||||
|
- "/Utilities/Script Editor.app"
|
||||||
|
- "/Utilities/System Information.app"
|
||||||
|
- "/Utilities/Terminal.app"
|
||||||
|
- "/Utilities/VoiceOver Utility.app"
|
||||||
|
- "/VoiceMemos.app"
|
||||||
|
- "/Weather.app"
|
||||||
|
- "/iMovie.app"
|
||||||
|
- "/iPhone Mirroring.app"
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
exclude:
|
||||||
|
- "/.Trash"
|
||||||
|
- "/tmp"
|
||||||
|
- "/Library/Caches"
|
||||||
|
- "/Library/Accounts"
|
||||||
|
- "/Library/AppleMediaServices"
|
||||||
|
- "/Library/Application Support/AddressBook"
|
||||||
|
- "/Library/Application Support/CallHistoryDB"
|
||||||
|
- "/Library/Application Support/CallHistoryTransactions"
|
||||||
|
- "/Library/Application Support/DifferentialPrivacy"
|
||||||
|
- "/Library/Application Support/FaceTime"
|
||||||
|
- "/Library/Application Support/FileProvider"
|
||||||
|
- "/Library/Application Support/Knowledge"
|
||||||
|
- "/Library/Application Support/com.apple.TCC"
|
||||||
|
- "/Library/Application Support/com.apple.avfoundation/Frecents"
|
||||||
|
- "/Library/Application Support/com.apple.sharedfilelist"
|
||||||
|
- "/Library/Assistant/SiriVocabulary"
|
||||||
|
- "/Library/Autosave Information"
|
||||||
|
- "/Library/Biome"
|
||||||
|
- "/Library/ContainerManager"
|
||||||
|
- "/Library/Containers/com.apple.Home"
|
||||||
|
- "/Library/Containers/com.apple.Maps/Data/Maps"
|
||||||
|
- "/Library/Containers/com.apple.MobileSMS"
|
||||||
|
- "/Library/Containers/com.apple.Notes"
|
||||||
|
- "/Library/Containers/com.apple.Safari"
|
||||||
|
- "/Library/Containers/com.apple.Safari.WebApp"
|
||||||
|
- "/Library/Containers/com.apple.VoiceMemos"
|
||||||
|
- "/Library/Containers/com.apple.archiveutility"
|
||||||
|
- "/Library/Containers/com.apple.corerecents.recentsd/Data/Library/Recents"
|
||||||
|
- "/Library/Containers/com.apple.mail"
|
||||||
|
- "/Library/Containers/com.apple.news"
|
||||||
|
- "/Library/Containers/com.apple.stocks"
|
||||||
|
- "/Library/Cookies"
|
||||||
|
- "/Library/CoreFollowUp"
|
||||||
|
- "/Library/Daemon Containers"
|
||||||
|
- "/Library/DoNotDisturb"
|
||||||
|
- "/Library/DuetExpertCenter"
|
||||||
|
- "/Library/Group Containers/com.apple.Home.group"
|
||||||
|
- "/Library/Group Containers/com.apple.MailPersonaStorage"
|
||||||
|
- "/Library/Group Containers/com.apple.PreviewLegacySignaturesConversion"
|
||||||
|
- "/Library/Group Containers/com.apple.bird"
|
||||||
|
- "/Library/Group Containers/com.apple.stickersd.group"
|
||||||
|
- "/Library/Group Containers/com.apple.systempreferences.cache"
|
||||||
|
- "/Library/Group Containers/group.com.apple.AppleSpell"
|
||||||
|
- "/Library/Group Containers/group.com.apple.ArchiveUtility.PKSignedContainer"
|
||||||
|
- "/Library/Group Containers/group.com.apple.DeviceActivity"
|
||||||
|
- "/Library/Group Containers/group.com.apple.Journal"
|
||||||
|
- "/Library/Group Containers/group.com.apple.ManagedSettings"
|
||||||
|
- "/Library/Group Containers/group.com.apple.PegasusConfiguration"
|
||||||
|
- "/Library/Group Containers/group.com.apple.Safari.SandboxBroker"
|
||||||
|
- "/Library/Group Containers/group.com.apple.SiriTTS"
|
||||||
|
- "/Library/Group Containers/group.com.apple.UserNotifications"
|
||||||
|
- "/Library/Group Containers/group.com.apple.VoiceMemos.shared"
|
||||||
|
- "/Library/Group Containers/group.com.apple.accessibility.voicebanking"
|
||||||
|
- "/Library/Group Containers/group.com.apple.amsondevicestoraged"
|
||||||
|
- "/Library/Group Containers/group.com.apple.appstoreagent"
|
||||||
|
- "/Library/Group Containers/group.com.apple.calendar"
|
||||||
|
- "/Library/Group Containers/group.com.apple.chronod"
|
||||||
|
- "/Library/Group Containers/group.com.apple.contacts"
|
||||||
|
- "/Library/Group Containers/group.com.apple.controlcenter"
|
||||||
|
- "/Library/Group Containers/group.com.apple.corerepair"
|
||||||
|
- "/Library/Group Containers/group.com.apple.coreservices.useractivityd"
|
||||||
|
- "/Library/Group Containers/group.com.apple.energykit"
|
||||||
|
- "/Library/Group Containers/group.com.apple.feedback"
|
||||||
|
- "/Library/Group Containers/group.com.apple.feedbacklogger"
|
||||||
|
- "/Library/Group Containers/group.com.apple.findmy.findmylocateagent"
|
||||||
|
- "/Library/Group Containers/group.com.apple.iCloudDrive"
|
||||||
|
- "/Library/Group Containers/group.com.apple.icloud.fmfcore"
|
||||||
|
- "/Library/Group Containers/group.com.apple.icloud.fmipcore"
|
||||||
|
- "/Library/Group Containers/group.com.apple.icloud.searchpartyuseragent"
|
||||||
|
- "/Library/Group Containers/group.com.apple.liveactivitiesd"
|
||||||
|
- "/Library/Group Containers/group.com.apple.loginwindow.persistent-apps"
|
||||||
|
- "/Library/Group Containers/group.com.apple.mail"
|
||||||
|
- "/Library/Group Containers/group.com.apple.mlhost"
|
||||||
|
- "/Library/Group Containers/group.com.apple.moments"
|
||||||
|
- "/Library/Group Containers/group.com.apple.news"
|
||||||
|
- "/Library/Group Containers/group.com.apple.newsd"
|
||||||
|
- "/Library/Group Containers/group.com.apple.notes"
|
||||||
|
- "/Library/Group Containers/group.com.apple.notes.import"
|
||||||
|
- "/Library/Group Containers/group.com.apple.photolibraryd.private"
|
||||||
|
- "/Library/Group Containers/group.com.apple.portrait.BackgroundReplacement"
|
||||||
|
- "/Library/Group Containers/group.com.apple.printtool"
|
||||||
|
- "/Library/Group Containers/group.com.apple.private.translation"
|
||||||
|
- "/Library/Group Containers/group.com.apple.reminders"
|
||||||
|
- "/Library/Group Containers/group.com.apple.replicatord"
|
||||||
|
- "/Library/Group Containers/group.com.apple.scopedbookmarkagent"
|
||||||
|
- "/Library/Group Containers/group.com.apple.secure-control-center-preferences"
|
||||||
|
- "/Library/Group Containers/group.com.apple.sharingd"
|
||||||
|
- "/Library/Group Containers/group.com.apple.shortcuts"
|
||||||
|
- "/Library/Group Containers/group.com.apple.siri.inference"
|
||||||
|
- "/Library/Group Containers/group.com.apple.siri.referenceResolution"
|
||||||
|
- "/Library/Group Containers/group.com.apple.siri.remembers"
|
||||||
|
- "/Library/Group Containers/group.com.apple.siri.userfeedbacklearning"
|
||||||
|
- "/Library/Group Containers/group.com.apple.spotlight"
|
||||||
|
- "/Library/Group Containers/group.com.apple.stocks"
|
||||||
|
- "/Library/Group Containers/group.com.apple.stocks-news"
|
||||||
|
- "/Library/Group Containers/group.com.apple.studentd"
|
||||||
|
- "/Library/Group Containers/group.com.apple.swtransparency"
|
||||||
|
- "/Library/Group Containers/group.com.apple.telephonyutilities.callservicesd"
|
||||||
|
- "/Library/Group Containers/group.com.apple.tips"
|
||||||
|
- "/Library/Group Containers/group.com.apple.tipsnext"
|
||||||
|
- "/Library/Group Containers/group.com.apple.transparency"
|
||||||
|
- "/Library/Group Containers/group.com.apple.usernoted"
|
||||||
|
- "/Library/Group Containers/group.com.apple.weather"
|
||||||
|
- "/Library/HomeKit"
|
||||||
|
- "/Library/IdentityServices"
|
||||||
|
- "/Library/IntelligencePlatform"
|
||||||
|
- "/Library/Mail"
|
||||||
|
- "/Library/Messages"
|
||||||
|
- "/Library/Metadata/CoreSpotlight"
|
||||||
|
- "/Library/Metadata/com.apple.IntelligentSuggestions"
|
||||||
|
- "/Library/PersonalizationPortrait"
|
||||||
|
- "/Library/Safari"
|
||||||
|
- "/Library/Sharing"
|
||||||
|
- "/Library/Shortcuts"
|
||||||
|
- "/Library/StatusKit"
|
||||||
|
- "/Library/Suggestions"
|
||||||
|
- "/Library/Trial"
|
||||||
|
- "/Library/Weather"
|
||||||
|
- "/Library/com.apple.aiml.instrumentation"
|
||||||
|
- "/Movies/TV"
|
||||||
|
system:
|
||||||
|
paths:
|
||||||
|
- /
|
||||||
|
exclude:
|
||||||
|
# Virtual/transient filesystems
|
||||||
|
- /proc
|
||||||
|
- /sys
|
||||||
|
- /dev
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
- /var/tmp
|
||||||
|
- /var/run
|
||||||
|
- /var/lock
|
||||||
|
- /var/cache
|
||||||
|
- /media
|
||||||
|
- /mnt
|
||||||
|
# Swap
|
||||||
|
- /swapfile
|
||||||
|
- /swap.img
|
||||||
|
# Package manager caches
|
||||||
|
- /var/cache/apt
|
||||||
|
- /var/cache/yum
|
||||||
|
- /var/cache/dnf
|
||||||
|
- /var/cache/pacman
|
||||||
|
# Trash
|
||||||
|
- "*/.local/share/Trash"
|
||||||
|
dev:
|
||||||
|
paths:
|
||||||
|
- /Users/user/dev
|
||||||
|
exclude:
|
||||||
|
- "**/node_modules"
|
||||||
|
- "**/target"
|
||||||
|
- "**/build"
|
||||||
|
- "**/__pycache__"
|
||||||
|
- "**/*.pyc"
|
||||||
|
- "**/.venv"
|
||||||
|
- "**/vendor"
|
||||||
|
|
||||||
|
# Global patterns to exclude from all backups
|
||||||
|
exclude:
|
||||||
|
- "*.tmp"
|
||||||
|
|
||||||
|
# Storage URL - use either this OR the s3 section below
|
||||||
|
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
||||||
|
# An s3:// URL also needs s3.access_key_id and s3.secret_access_key below
|
||||||
|
# A file:// path must be absolute, and the directory must already exist:
|
||||||
|
# vaultik never creates it
|
||||||
|
# An rclone:// URL needs the remote to be defined in rclone's own config
|
||||||
|
# (rclone config)
|
||||||
|
storage_url: "rclone://myremote/path/to/backups"
|
||||||
|
|
||||||
|
# S3-compatible storage configuration
|
||||||
|
#s3:
|
||||||
|
# # S3-compatible endpoint URL
|
||||||
|
# # Examples: https://s3.amazonaws.com, https://storage.googleapis.com
|
||||||
|
# endpoint: https://s3.example.com
|
||||||
|
#
|
||||||
|
# # Bucket name where backups will be stored
|
||||||
|
# bucket: mybucket
|
||||||
|
#
|
||||||
|
# # Prefix (folder) within the bucket for this host's backups
|
||||||
|
# # Useful for organizing backups from multiple hosts
|
||||||
|
# # Default: empty (root of bucket)
|
||||||
|
# #prefix: "hosts/myserver/"
|
||||||
|
#
|
||||||
|
# # S3 access credentials
|
||||||
|
# access_key_id: YOUR_ACCESS_KEY
|
||||||
|
# secret_access_key: YOUR_SECRET_KEY
|
||||||
|
#
|
||||||
|
# # S3 region
|
||||||
|
# # Default: us-east-1
|
||||||
|
# #region: us-east-1
|
||||||
|
#
|
||||||
|
# # Use SSL/TLS for S3 connections
|
||||||
|
# # Only read when storage_url is not set and the endpoint has no scheme;
|
||||||
|
# # with an s3:// storage_url, use ?ssl=false in the URL instead
|
||||||
|
# # Default: true
|
||||||
|
# #use_ssl: true
|
||||||
|
#
|
||||||
|
# # Part size for multipart uploads
|
||||||
|
# # Minimum 5MiB, maximum 5GiB; affects memory usage during upload
|
||||||
|
# # A blob too large for 10,000 parts of this size gets larger parts
|
||||||
|
# # Supports: 10MB, 16MiB, 100MiB, etc. (5MB is below the minimum)
|
||||||
|
# # Default: 5MiB
|
||||||
|
# #part_size: 5MiB
|
||||||
|
|
||||||
|
# Path to local SQLite index database
|
||||||
|
# This database tracks file state for incremental backups
|
||||||
|
# The VAULTIK_INDEX_PATH environment variable overrides this setting
|
||||||
|
# Default: the platform data directory, e.g.
|
||||||
|
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||||
|
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||||
|
#index_path: /path/to/index.sqlite
|
||||||
|
|
||||||
|
# Average chunk size for content-defined chunking
|
||||||
|
# Smaller chunks = better deduplication but more metadata
|
||||||
|
# Minimum 1MB
|
||||||
|
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
|
||||||
|
# Default: 10MB
|
||||||
|
#chunk_size: 10MB
|
||||||
|
|
||||||
|
# Maximum blob size
|
||||||
|
# Multiple chunks are packed into blobs up to this size
|
||||||
|
# Must be at least four times chunk_size (the largest chunk the chunker can
|
||||||
|
# emit); a smaller limit would let a single-chunk blob exceed it.
|
||||||
|
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a
|
||||||
|
# large limit a blob holds hundreds of chunks, so individual chunk lengths are
|
||||||
|
# not visible in its size; lowering the limit toward chunk_size exposes them.
|
||||||
|
# A backup needs free temporary space, because each blob is written whole to
|
||||||
|
# a temporary file before it is uploaded (up to about blob_size_limit; an
|
||||||
|
# rclone destination that cannot stream uploads needs about twice that) and
|
||||||
|
# the metadata export writes copies of the local index. Temporary files go to
|
||||||
|
# $TMPDIR (default /tmp); with TMPDIR unset, SQLite writes one of those copies
|
||||||
|
# to /var/tmp.
|
||||||
|
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
||||||
|
# Default: 10GB
|
||||||
|
#blob_size_limit: 10GB
|
||||||
|
|
||||||
|
# Compression level (1-19)
|
||||||
|
# Higher = better compression but slower
|
||||||
|
# Default: 3
|
||||||
|
compression_level: 5
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it, and
|
||||||
|
# how old every object in a directory under metadata/ with no manifest must
|
||||||
|
# be before prune deletes that directory
|
||||||
|
# A backup uploads its blobs and its db.zst.age before its manifest, so on a
|
||||||
|
# destination shared between hosts or users this must be longer than the
|
||||||
|
# longest backup run
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that stopped
|
||||||
|
# before its manifest: that blob keeps the time of its first upload, so a
|
||||||
|
# prune can delete it
|
||||||
|
# Supports: 36h, 90m, etc.; 0s deletes such blobs and directories at once
|
||||||
|
# Default: 24h
|
||||||
|
#prune_grace_period: 24h
|
||||||
|
|
||||||
|
# Hostname to use in backup metadata
|
||||||
|
# Default: system hostname
|
||||||
|
#hostname: myserver
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# vaultik data model
|
||||||
|
|
||||||
|
`vaultik` keeps a SQLite database on the backed-up machine, called the local
|
||||||
|
index. This document describes its tables and how each command uses them.
|
||||||
|
|
||||||
|
The local index has two jobs:
|
||||||
|
|
||||||
|
- It records which files, chunks and blobs are already stored. The next backup
|
||||||
|
then does not store them again.
|
||||||
|
- It is the source of each snapshot's own database. A backup uploads a trimmed,
|
||||||
|
encrypted copy of the index with every snapshot.
|
||||||
|
|
||||||
|
A restore does not need the local index. It reads the copy that was uploaded
|
||||||
|
with the snapshot.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
||||||
|
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
|
||||||
|
content.
|
||||||
|
- **Blob**: one object at the destination. It holds many chunks, compressed and
|
||||||
|
then encrypted.
|
||||||
|
- **Destination**: the place the backups are stored.
|
||||||
|
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
|
||||||
|
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
|
||||||
|
at the destination.
|
||||||
|
|
||||||
|
## Where the index is
|
||||||
|
|
||||||
|
The index is the file `index.sqlite` in the platform data directory. That is
|
||||||
|
`~/.local/share/vaultik/` on Linux and `~/Library/Application Support/vaultik/`
|
||||||
|
on macOS. The `index_path` setting and the `VAULTIK_INDEX_PATH` environment
|
||||||
|
variable change the path.
|
||||||
|
|
||||||
|
The file is readable only by its owner, because it lists every backed-up path.
|
||||||
|
`vaultik` sets that mode each time it opens the index.
|
||||||
|
|
||||||
|
## Schema migrations
|
||||||
|
|
||||||
|
`vaultik` supports schema migrations. They are the numbered SQL files in
|
||||||
|
`internal/database/schema/`, which are built into the binary.
|
||||||
|
|
||||||
|
- `000.sql` creates the `schema_migrations` table. That table records each
|
||||||
|
migration that has run.
|
||||||
|
- `001.sql` creates every other table.
|
||||||
|
|
||||||
|
`database.New` opens the index. It then applies, in order, every migration that
|
||||||
|
the index has not recorded.
|
||||||
|
|
||||||
|
Before version 1.0, no migration is added, because nothing is installed anywhere
|
||||||
|
yet. A schema change edits `001.sql` directly, with the code that uses the
|
||||||
|
changed tables.
|
||||||
|
|
||||||
|
This has one consequence. An index created before such a change has already
|
||||||
|
recorded `001.sql` as run, so it keeps the old schema and can become unusable.
|
||||||
|
To rebuild it, run `vaultik database delete` and then a full backup.
|
||||||
|
|
||||||
|
After version 1.0, each schema change is a new numbered file. An existing index
|
||||||
|
is then migrated the first time an updated `vaultik` opens it. A file that was
|
||||||
|
part of a release is never edited.
|
||||||
|
|
||||||
|
Migrations apply to the local index only. A snapshot's database at the
|
||||||
|
destination is read as it was written. Each snapshot records the `vaultik`
|
||||||
|
version that wrote it.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
Times are whole seconds since the Unix epoch, in UTC. The two exceptions are
|
||||||
|
noted at their columns.
|
||||||
|
|
||||||
|
### `files`
|
||||||
|
|
||||||
|
One row for each backed-up path: a regular file, a symlink or a directory.
|
||||||
|
|
||||||
|
- `id`: a UUID. Primary key.
|
||||||
|
- `path`: the absolute path. Unique.
|
||||||
|
- `source_path`: the configured path that the scan found this entry under. A
|
||||||
|
restore does not read it.
|
||||||
|
- `mtime`: the modification time.
|
||||||
|
- `mtime_nsec`: the nanoseconds within that second.
|
||||||
|
- `size`: the size in bytes.
|
||||||
|
- `mode`: the permission bits and the file type.
|
||||||
|
- `uid`: the owner's user ID.
|
||||||
|
- `gid`: the owner's group ID.
|
||||||
|
- `link_target`: the target of a symlink. It is `NULL` for other entries.
|
||||||
|
|
||||||
|
`idx_files_path` indexes `path`.
|
||||||
|
|
||||||
|
There is one row for each path, and it is updated when the file changes. The
|
||||||
|
row is shared by every snapshot in the local index that lists the path. An
|
||||||
|
older snapshot's own view of the file is in that snapshot's database at the
|
||||||
|
destination.
|
||||||
|
|
||||||
|
### `chunks`
|
||||||
|
|
||||||
|
One row for each distinct chunk.
|
||||||
|
|
||||||
|
- `chunk_hash`: the SHA-256 hash of the chunk's content, in hex. Primary key.
|
||||||
|
- `size`: the size in bytes.
|
||||||
|
|
||||||
|
### `file_chunks`
|
||||||
|
|
||||||
|
The chunks that make up each file, in order.
|
||||||
|
|
||||||
|
- `file_id`: the file. References `files.id`.
|
||||||
|
- `idx`: the position of the chunk within the file, counted from 0.
|
||||||
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
||||||
|
|
||||||
|
The primary key is (`file_id`, `idx`). `idx_file_chunks_chunk_hash` indexes
|
||||||
|
`chunk_hash`.
|
||||||
|
|
||||||
|
### `chunk_files`
|
||||||
|
|
||||||
|
The reverse of `file_chunks`: the files that contain each chunk.
|
||||||
|
|
||||||
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
||||||
|
- `file_id`: the file. References `files.id`.
|
||||||
|
- `file_offset`: the byte offset of the chunk within the file.
|
||||||
|
- `length`: the length of the chunk in bytes.
|
||||||
|
|
||||||
|
The primary key is (`chunk_hash`, `file_id`). `idx_chunk_files_file_id` indexes
|
||||||
|
`file_id`.
|
||||||
|
|
||||||
|
### `blobs`
|
||||||
|
|
||||||
|
One row for each blob.
|
||||||
|
|
||||||
|
- `id`: a UUID, given when packing of the blob starts. Primary key.
|
||||||
|
- `blob_hash`: the name of the blob at the destination. Unique. Until the blob
|
||||||
|
is closed, it holds a placeholder that starts with `temp-placeholder-`.
|
||||||
|
- `created_ts`: when packing started.
|
||||||
|
- `finished_ts`: when the blob was closed. It is `NULL` before that.
|
||||||
|
- `uncompressed_size`: the total size of the blob's chunks.
|
||||||
|
- `compressed_size`: the size after compression and encryption.
|
||||||
|
- `uploaded_ts`: when the upload finished. It is `NULL` before that.
|
||||||
|
|
||||||
|
`blob_hash` is `hex(SHA256(SHA256(uncompressed contents)))`. See
|
||||||
|
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs).
|
||||||
|
|
||||||
|
Once a blob is uploaded, `compressed_size` is the size of the object at the
|
||||||
|
destination. A blob that was already there can have a different size from the
|
||||||
|
local copy. That happens when it was written with another compression level or
|
||||||
|
other recipients.
|
||||||
|
|
||||||
|
### `blob_chunks`
|
||||||
|
|
||||||
|
The chunks that each blob holds.
|
||||||
|
|
||||||
|
- `blob_id`: the blob. References `blobs.id`.
|
||||||
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
||||||
|
- `offset`: the byte offset of the chunk within the uncompressed blob.
|
||||||
|
- `length`: the length of the chunk in bytes.
|
||||||
|
|
||||||
|
The primary key is (`blob_id`, `chunk_hash`). `idx_blob_chunks_chunk_hash`
|
||||||
|
indexes `chunk_hash`.
|
||||||
|
|
||||||
|
A restore finds the blob for each chunk through this table.
|
||||||
|
|
||||||
|
### `snapshots`
|
||||||
|
|
||||||
|
One row for each snapshot.
|
||||||
|
|
||||||
|
- `id`: the snapshot ID. Primary key.
|
||||||
|
- `hostname`: the hostname of the machine that made the backup.
|
||||||
|
- `vaultik_version`: the `vaultik` version that made it.
|
||||||
|
- `vaultik_git_revision`: the git commit of that version.
|
||||||
|
- `started_at`: when the backup started.
|
||||||
|
- `completed_at`: when the backup finished. It is `NULL` until then.
|
||||||
|
- `file_count`: the number of files and symlinks in the snapshot. Directories
|
||||||
|
are not counted.
|
||||||
|
- `chunk_count`: the number of chunks this snapshot stored that were not stored
|
||||||
|
before.
|
||||||
|
- `new_chunk_size`: the total size of those chunks before compression.
|
||||||
|
- `blob_count`: the number of blobs this snapshot created.
|
||||||
|
- `total_size`: the total size of all files in the snapshot.
|
||||||
|
- `uncompressed_chunk_size`: the total size, before compression, of the
|
||||||
|
distinct chunks the snapshot's files use.
|
||||||
|
- `blob_size`: the total compressed size of the blobs the snapshot uses.
|
||||||
|
- `blob_uncompressed_size`: the total uncompressed size of those blobs.
|
||||||
|
- `compression_ratio`: the compression ratio of those blobs.
|
||||||
|
- `compression_level`: the compression level the backup used.
|
||||||
|
- `upload_bytes`: the bytes uploaded during the backup.
|
||||||
|
- `upload_duration_ms`: the time spent uploading, in milliseconds.
|
||||||
|
|
||||||
|
The snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An
|
||||||
|
example is `server1_home_2025-06-01T12:00:00Z`. It has three parts:
|
||||||
|
|
||||||
|
- the hostname, up to its first `.`
|
||||||
|
- the snapshot name
|
||||||
|
- the start time, in UTC
|
||||||
|
|
||||||
|
`snapshot list` shows `uncompressed_chunk_size` as `UNCOMPRESSED SIZE` and
|
||||||
|
`new_chunk_size` as `NEW CHUNK SIZE`.
|
||||||
|
|
||||||
|
A snapshot with `completed_at` set to `NULL` is unfinished. `snapshot list`
|
||||||
|
does not show it. The next backup deletes it, and so does `prune`.
|
||||||
|
|
||||||
|
### `snapshot_files`
|
||||||
|
|
||||||
|
The files in each snapshot.
|
||||||
|
|
||||||
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
||||||
|
- `file_id`: the file. References `files.id`.
|
||||||
|
|
||||||
|
The primary key is (`snapshot_id`, `file_id`). `idx_snapshot_files_file_id`
|
||||||
|
indexes `file_id`.
|
||||||
|
|
||||||
|
### `snapshot_blobs`
|
||||||
|
|
||||||
|
The blobs that each snapshot uses.
|
||||||
|
|
||||||
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
||||||
|
- `blob_id`: the blob. References `blobs.id`.
|
||||||
|
- `blob_hash`: a copy of the blob's hash.
|
||||||
|
|
||||||
|
The primary key is (`snapshot_id`, `blob_id`). `idx_snapshot_blobs_blob_id`
|
||||||
|
indexes `blob_id`.
|
||||||
|
|
||||||
|
The manifest is built from this table.
|
||||||
|
|
||||||
|
### `snapshot_mount_points`
|
||||||
|
|
||||||
|
The mount points that a snapshot's backup found.
|
||||||
|
|
||||||
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
||||||
|
- `path`: the absolute path of the directory.
|
||||||
|
|
||||||
|
The primary key is (`snapshot_id`, `path`).
|
||||||
|
|
||||||
|
A directory is recorded here when all of these hold:
|
||||||
|
|
||||||
|
- It is at or below a configured path.
|
||||||
|
- The backup found an entry in it that is not excluded.
|
||||||
|
- It is on a different filesystem from its parent directory.
|
||||||
|
|
||||||
|
The device number is read after the directory has been listed. Listing an
|
||||||
|
automount point mounts its volume, so the number is that of the volume. An
|
||||||
|
automount point whose volume cannot be mounted is not recorded, because the
|
||||||
|
backup finds no entry in it.
|
||||||
|
|
||||||
|
`snapshot create --prune` reads this table. It skips the prune when an earlier
|
||||||
|
snapshot of the same name recorded a mount point that the new backup did not
|
||||||
|
find. That happens when the volume is not mounted.
|
||||||
|
|
||||||
|
### `uploads`
|
||||||
|
|
||||||
|
One row for each blob that a backup uploaded.
|
||||||
|
|
||||||
|
- `blob_hash`: the blob. References `blobs.blob_hash`. Primary key.
|
||||||
|
- `snapshot_id`: the snapshot whose backup uploaded it. References
|
||||||
|
`snapshots.id`.
|
||||||
|
- `uploaded_at`: when the upload started. It is written as a date and time, not
|
||||||
|
as seconds.
|
||||||
|
- `size`: the size of the uploaded blob.
|
||||||
|
- `duration_ms`: the duration of the upload, in milliseconds.
|
||||||
|
|
||||||
|
`idx_uploads_snapshot_id` indexes `snapshot_id`.
|
||||||
|
|
||||||
|
A blob that was already at the destination gets no row here.
|
||||||
|
|
||||||
|
### `local_meta`
|
||||||
|
|
||||||
|
Settings that belong to this copy of the index.
|
||||||
|
|
||||||
|
- `key`: the name of the setting. Primary key.
|
||||||
|
- `value`: its value.
|
||||||
|
|
||||||
|
The only key is `storage_url`. It holds the destination that the index was
|
||||||
|
first used with. A command that changes data refuses to run when the config
|
||||||
|
names a different destination.
|
||||||
|
|
||||||
|
### `schema_migrations`
|
||||||
|
|
||||||
|
The migrations that have run.
|
||||||
|
|
||||||
|
- `version`: the number of the migration. Primary key.
|
||||||
|
- `applied_at`: when it ran. It is written as a date and time, not as seconds.
|
||||||
|
|
||||||
|
## How commands use the tables
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
A backup works on the tables in this order:
|
||||||
|
|
||||||
|
- It deletes unfinished snapshots, and the rows that no snapshot uses.
|
||||||
|
- It creates a row in `snapshots`, with `completed_at` set to `NULL`.
|
||||||
|
- It deletes the `blobs` rows that were never uploaded.
|
||||||
|
- It walks each path and compares every entry with its row in `files`.
|
||||||
|
- It links each unchanged file to the snapshot in `snapshot_files`.
|
||||||
|
- It records each mount point in `snapshot_mount_points`.
|
||||||
|
- It reads each changed file and stores it. See below.
|
||||||
|
- It fills `snapshot_blobs` with every blob the snapshot uses.
|
||||||
|
- It stores the snapshot's totals in `snapshots`.
|
||||||
|
- It uploads the snapshot's metadata.
|
||||||
|
- It sets `completed_at`.
|
||||||
|
|
||||||
|
A file counts as changed when its row in `files` differs from the file on disk
|
||||||
|
in any of these:
|
||||||
|
|
||||||
|
- size
|
||||||
|
- modification time
|
||||||
|
- mode
|
||||||
|
- owner or group
|
||||||
|
- link target
|
||||||
|
|
||||||
|
A file is also read again when it lists a chunk that no uploaded blob holds.
|
||||||
|
|
||||||
|
Storing a changed file writes these rows:
|
||||||
|
|
||||||
|
- A row in `blobs`, when packing of a blob starts.
|
||||||
|
- When the blob is closed, in one transaction:
|
||||||
|
- a row in `chunks` for each new chunk in the blob
|
||||||
|
- the blob's rows in `blob_chunks`
|
||||||
|
- the blob's `blob_hash`, sizes and `finished_ts`
|
||||||
|
- When the blob is stored at the destination, in one transaction:
|
||||||
|
- `uploaded_ts` and `compressed_size` in `blobs`
|
||||||
|
- a row in `snapshot_blobs`
|
||||||
|
- a row in `uploads`, unless the blob was already at the destination
|
||||||
|
- The row in `files`, with its `file_chunks`, `chunk_files` and
|
||||||
|
`snapshot_files` rows.
|
||||||
|
|
||||||
|
The row in `files` is written last. It is written only after every blob that
|
||||||
|
holds the file's new chunks is stored.
|
||||||
|
|
||||||
|
### Snapshot metadata
|
||||||
|
|
||||||
|
The metadata of a snapshot is made from a copy of the index:
|
||||||
|
|
||||||
|
- The index is copied into a temporary directory with `VACUUM INTO`.
|
||||||
|
- The rows of every other snapshot are deleted from the copy. So are the files
|
||||||
|
and blobs that this snapshot does not use. A chunk is then deleted when no
|
||||||
|
remaining file and no remaining blob uses it.
|
||||||
|
- The `local_meta` and `schema_migrations` tables are kept as they are. The
|
||||||
|
uploaded database therefore names the destination, and lists the migrations
|
||||||
|
that have run.
|
||||||
|
- `VACUUM` rewrites the copy, so that no deleted row is left in the file.
|
||||||
|
- The copy is compressed with zstd and encrypted with age.
|
||||||
|
- The manifest is built from the copy's `snapshot_blobs` table.
|
||||||
|
- The copy is uploaded as `metadata/<remote-key>/db.zst.age`.
|
||||||
|
- The manifest is uploaded as `metadata/<remote-key>/manifest.json.zst`.
|
||||||
|
|
||||||
|
The copy is made through SQLite, not by copying the file. The index runs in WAL
|
||||||
|
mode, so rows that are already committed can still be in the `-wal` file beside
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
A restore does not use the local index.
|
||||||
|
|
||||||
|
- It downloads and decrypts `db.zst.age` into a temporary directory.
|
||||||
|
- It opens that file read-only. It applies no migration to it.
|
||||||
|
- It reads the files to restore from `files`.
|
||||||
|
- It finds each file's chunks in `file_chunks`.
|
||||||
|
- It finds the blob and offset of each chunk in `blob_chunks`.
|
||||||
|
- It finds the name of each blob in `blobs`.
|
||||||
|
|
||||||
|
It does not read the manifest.
|
||||||
|
|
||||||
|
### Prune
|
||||||
|
|
||||||
|
`prune` changes the local index in two ways:
|
||||||
|
|
||||||
|
- It deletes each snapshot whose manifest is gone from the destination.
|
||||||
|
- It deletes unfinished snapshots, and the rows that no snapshot uses.
|
||||||
|
|
||||||
|
The second step removes unused rows in this order:
|
||||||
|
|
||||||
|
- files that are in no snapshot
|
||||||
|
- blobs that are in no snapshot
|
||||||
|
- `blob_chunks` rows whose chunk is gone
|
||||||
|
- chunks that no file and no blob uses
|
||||||
|
|
||||||
|
`prune` then deletes unused data at the destination. That step reads the
|
||||||
|
manifests, not the local index. It reads the local index only to keep a
|
||||||
|
directory under `metadata/` whose snapshot is still recorded there.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Each repository type in `internal/database` serves one or more tables:
|
||||||
|
|
||||||
|
- `FileRepository`: `files`
|
||||||
|
- `ChunkRepository`: `chunks`
|
||||||
|
- `FileChunkRepository`: `file_chunks`
|
||||||
|
- `ChunkFileRepository`: `chunk_files`
|
||||||
|
- `BlobRepository`: `blobs`
|
||||||
|
- `BlobChunkRepository`: `blob_chunks`
|
||||||
|
- `SnapshotRepository`:
|
||||||
|
- `snapshots`
|
||||||
|
- `snapshot_files`
|
||||||
|
- `snapshot_blobs`
|
||||||
|
- `snapshot_mount_points`
|
||||||
|
- `UploadRepository`: `uploads`
|
||||||
|
- `LocalMetaRepository`: `local_meta`
|
||||||
|
|
||||||
|
No repository type serves `schema_migrations`. The code that applies the
|
||||||
|
migrations reads and writes it.
|
||||||
|
|
||||||
|
`Repositories` holds one of each type.
|
||||||
|
|
||||||
|
`Repositories.WithTx` runs a function in one transaction:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
// several repository calls that use tx
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The transaction commits when the function returns no error. Otherwise it is
|
||||||
|
rolled back.
|
||||||
|
|
||||||
|
## SQLite settings
|
||||||
|
|
||||||
|
- The index runs in WAL mode. A command that only reads, such as
|
||||||
|
`snapshot list`, can read the index while a backup writes to it.
|
||||||
|
- A connection waits up to 10 seconds for a lock before it fails.
|
||||||
|
- Foreign keys are enforced.
|
||||||
|
- Each open database uses one connection, so its queries run one at a time.
|
||||||
|
- Queries pass their values as parameters.
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
# vaultik destination layout
|
||||||
|
|
||||||
|
This document describes what `vaultik` stores at the destination. It covers the
|
||||||
|
layout, what the layout protects, and the risks it accepts.
|
||||||
|
|
||||||
|
The destination is the place the backups are stored. It is an S3 bucket and
|
||||||
|
prefix, a local directory or an rclone remote. The layout is the same on all
|
||||||
|
three.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
||||||
|
- **Snapshot ID**: the name of a snapshot, in the form
|
||||||
|
`<hostname>_<snapshot-name>_<timestamp>`.
|
||||||
|
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
|
||||||
|
content.
|
||||||
|
- **Blob**: one object under `blobs/`. It holds many chunks.
|
||||||
|
- **Local index**: the SQLite database on the backed-up machine.
|
||||||
|
- **Recipient**: an age public key that backups are encrypted to.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
<destination>/
|
||||||
|
├── vaultik-destination.txt
|
||||||
|
├── blobs/
|
||||||
|
│ └── <hash[0:2]>/
|
||||||
|
│ └── <hash[2:4]>/
|
||||||
|
│ └── <hash>
|
||||||
|
└── metadata/
|
||||||
|
└── <remote-key>/
|
||||||
|
├── db.zst.age
|
||||||
|
└── manifest.json.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
The destination holds three things:
|
||||||
|
|
||||||
|
- `vaultik-destination.txt`, the marker file
|
||||||
|
- `blobs/`, which holds the file contents
|
||||||
|
- `metadata/`, which holds one directory for each snapshot
|
||||||
|
|
||||||
|
## The marker file
|
||||||
|
|
||||||
|
Every backup writes `vaultik-destination.txt` before it uploads anything else.
|
||||||
|
The file holds one fixed line of text. It names no host, path or time.
|
||||||
|
|
||||||
|
The marker tells an emptied or missing destination apart from a new one. An
|
||||||
|
unplugged volume leaves an empty directory at its mount point. Without the
|
||||||
|
marker, that directory would look like a destination whose snapshots are all
|
||||||
|
gone.
|
||||||
|
|
||||||
|
The check applies while the local index holds snapshots. When the destination
|
||||||
|
then answers that the marker does not exist, commands treat the destination as
|
||||||
|
not present:
|
||||||
|
|
||||||
|
- `snapshot create` refuses to write to it.
|
||||||
|
- `prune`, `snapshot purge` and `remote nuke` fail.
|
||||||
|
- `snapshot list` shows the local index alone.
|
||||||
|
- `snapshot remove` deletes nothing at the destination.
|
||||||
|
|
||||||
|
The check is skipped when the look-up fails for any other reason. S3 refuses
|
||||||
|
the look-up to credentials that can only write. A machine with such credentials
|
||||||
|
therefore does not notice an emptied destination. Its next backup would not
|
||||||
|
upload data that its local index says is stored. After a destination is emptied,
|
||||||
|
run `vaultik database delete` on such a machine before it backs up again.
|
||||||
|
|
||||||
|
`remote nuke` deletes the marker last.
|
||||||
|
|
||||||
|
## Blobs
|
||||||
|
|
||||||
|
A blob holds the content of many chunks, one after another.
|
||||||
|
|
||||||
|
- **Path**: `blobs/<first 2 characters>/<next 2 characters>/<full hash>`.
|
||||||
|
- **Example**:
|
||||||
|
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`.
|
||||||
|
- **Format**: the chunk data, compressed with zstd and then encrypted with age.
|
||||||
|
- **Recipients**: the public keys in `age_recipients` when the blob was written.
|
||||||
|
|
||||||
|
The two directory levels keep any one directory from holding too many objects.
|
||||||
|
|
||||||
|
### Blob names
|
||||||
|
|
||||||
|
A blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. The contents are
|
||||||
|
the chunk data, joined in order, before compression and encryption. The name is
|
||||||
|
not a hash of the stored bytes.
|
||||||
|
|
||||||
|
This has two consequences:
|
||||||
|
|
||||||
|
- Checking a blob against its name needs the private key. The blob must be
|
||||||
|
decrypted and decompressed first. `snapshot restore` and
|
||||||
|
`snapshot verify --deep` do this.
|
||||||
|
- Without the key, only a blob's presence and size can be checked.
|
||||||
|
`snapshot verify` does this.
|
||||||
|
|
||||||
|
`blobgen.DoubleSHA256` in `internal/blobgen` computes the name.
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
|
||||||
|
A backup looks a blob up by name before it uploads it. It does not upload a
|
||||||
|
blob that is already there.
|
||||||
|
|
||||||
|
When the destination refuses the look-up, the backup uploads the blob. An
|
||||||
|
object with that name is then replaced by one that holds the same chunk data.
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
Each snapshot has one directory under `metadata/`. The directory is named with
|
||||||
|
the snapshot's remote key, not with its snapshot ID.
|
||||||
|
|
||||||
|
### Snapshot IDs
|
||||||
|
|
||||||
|
A snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An example
|
||||||
|
is `laptop_home_2024-01-15T14:30:52Z`. It has three parts:
|
||||||
|
|
||||||
|
- the hostname, up to its first `.`
|
||||||
|
- the snapshot name from the config
|
||||||
|
- the time the backup started, in UTC
|
||||||
|
|
||||||
|
The snapshot ID is used on the command line, in log records and in the local
|
||||||
|
index. It reveals the hostname, the snapshot name and the backup time. It is
|
||||||
|
therefore never written to the destination without encryption.
|
||||||
|
|
||||||
|
### Remote keys
|
||||||
|
|
||||||
|
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot ID)))`. It is 64 hex
|
||||||
|
characters long. `internal/snapshot/remotekey.go` computes it.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
||||||
|
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
||||||
|
- Directory:
|
||||||
|
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||||
|
|
||||||
|
A listing of the destination therefore shows no hostname and no snapshot name.
|
||||||
|
|
||||||
|
The hash cannot be reversed, but it uses no secret. Someone who guesses a
|
||||||
|
snapshot ID can hash it the same way and see whether that snapshot exists. The
|
||||||
|
remote key keeps names out of a listing. It does not hide them from a guess.
|
||||||
|
|
||||||
|
### `db.zst.age`
|
||||||
|
|
||||||
|
The snapshot's database. A restore needs it.
|
||||||
|
|
||||||
|
- **Contents**: a SQLite database. It is a copy of the local index without the
|
||||||
|
rows of other snapshots. It lists every file in the snapshot with its
|
||||||
|
metadata, its chunks, and the blob that holds each chunk.
|
||||||
|
- **Format**: the database file, compressed with zstd and then encrypted with
|
||||||
|
age.
|
||||||
|
|
||||||
|
It is encrypted because it holds file paths, permissions and owners.
|
||||||
|
[DATAMODEL.md](DATAMODEL.md) describes its tables.
|
||||||
|
|
||||||
|
### `manifest.json.zst`
|
||||||
|
|
||||||
|
The list of the blobs the snapshot uses.
|
||||||
|
|
||||||
|
- **Format**: JSON, compressed with zstd. It is not encrypted.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
||||||
|
"timestamp": "2025-06-01T12:04:31Z",
|
||||||
|
"blob_count": 2,
|
||||||
|
"total_compressed_size": 57344,
|
||||||
|
"blobs": [
|
||||||
|
{
|
||||||
|
"hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
|
"compressed_size": 24576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
|
"compressed_size": 32768
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `snapshot_id`: the remote key, not the snapshot ID.
|
||||||
|
- `timestamp`: when the manifest was written, at the end of the backup.
|
||||||
|
- `blob_count`: the number of blobs.
|
||||||
|
- `total_compressed_size`: the total size of those blobs, in bytes.
|
||||||
|
- `blobs`: the hash and the size of each blob.
|
||||||
|
|
||||||
|
The manifest holds no file name and no path.
|
||||||
|
|
||||||
|
The manifest is not encrypted so that these commands work without the private
|
||||||
|
key:
|
||||||
|
|
||||||
|
- `prune`, which must know which blobs are still in use
|
||||||
|
- `snapshot verify`, which checks that each blob is present
|
||||||
|
- `snapshot list` and `remote info`, which report what the destination holds
|
||||||
|
|
||||||
|
## What is protected
|
||||||
|
|
||||||
|
These are encrypted:
|
||||||
|
|
||||||
|
- file contents, in the blobs
|
||||||
|
- in `db.zst.age`:
|
||||||
|
- file names and paths
|
||||||
|
- permissions and owners
|
||||||
|
- modification times
|
||||||
|
- which chunks make up which file
|
||||||
|
|
||||||
|
These are not encrypted:
|
||||||
|
|
||||||
|
- the remote key of each snapshot
|
||||||
|
- the time each backup finished, in the manifest
|
||||||
|
- the hash and the compressed size of each blob, in the manifest
|
||||||
|
- the number of blobs and their total size for each snapshot, in the manifest
|
||||||
|
|
||||||
|
Someone who can read the destination therefore learns these:
|
||||||
|
|
||||||
|
- when each backup finished
|
||||||
|
- how many blobs each snapshot uses, and how large they are
|
||||||
|
- which blobs two snapshots share
|
||||||
|
- whether a snapshot ID they guess exists
|
||||||
|
|
||||||
|
They do not learn these:
|
||||||
|
|
||||||
|
- any hostname or snapshot name, unless they guess it
|
||||||
|
- file names or paths
|
||||||
|
- file contents
|
||||||
|
- permissions or owners
|
||||||
|
- the directory structure
|
||||||
|
- which chunks belong to which file
|
||||||
|
|
||||||
|
The times and sizes could not be hidden by encrypting the manifest. The
|
||||||
|
destination itself shows when each object was written and how large it is.
|
||||||
|
|
||||||
|
## Accepted risks
|
||||||
|
|
||||||
|
These are known properties of the format. They are written down so that you can
|
||||||
|
weigh them.
|
||||||
|
|
||||||
|
### No proof of who wrote the data
|
||||||
|
|
||||||
|
A restore and `snapshot verify --deep` prove two things. The data decrypts with
|
||||||
|
your private key, and it matches its hashes. They do not prove who wrote it.
|
||||||
|
|
||||||
|
The hashes use no secret. Someone who knows a recipient's public key and can
|
||||||
|
replace objects at the destination can put a snapshot of their own there. The
|
||||||
|
public key is not stored at the destination, but a backed-up machine that is
|
||||||
|
broken into has it.
|
||||||
|
|
||||||
|
The defences are on the destination's side:
|
||||||
|
|
||||||
|
- object versioning or object lock on the bucket
|
||||||
|
- credentials for the backed-up machine that cannot delete or overwrite
|
||||||
|
- running `prune` from a trusted machine
|
||||||
|
|
||||||
|
An S3 `PutObject` replaces an existing object. Permission to write alone
|
||||||
|
therefore does not stop overwrites.
|
||||||
|
|
||||||
|
### Sizes are visible
|
||||||
|
|
||||||
|
An object's size follows exactly from the size of its compressed contents.
|
||||||
|
|
||||||
|
All the new chunks in one blob share one zstd stream. The stream looks back 8
|
||||||
|
MiB, or 4 MiB at compression levels 1 and 2. A blob is closed at
|
||||||
|
`blob_size_limit` and at the end of each configured path. A chunk that is
|
||||||
|
already stored is never packed again.
|
||||||
|
|
||||||
|
Data that an outsider writes compresses smaller when it matches a secret in the
|
||||||
|
same chunk, so the blob gets smaller. The blob's size therefore shows the
|
||||||
|
outsider whether their data matched the secret.
|
||||||
|
|
||||||
|
This works only when all of these hold:
|
||||||
|
|
||||||
|
- The outsider can write into a backed-up file.
|
||||||
|
- The outsider can see the blob sizes at the destination.
|
||||||
|
- The outsider's data and the secret land in the same chunk.
|
||||||
|
- The file keeps changing.
|
||||||
|
|
||||||
|
Back up a directory that outsiders can write to as a snapshot of its own.
|
||||||
|
|
||||||
|
### Chunk boundaries use no secret
|
||||||
|
|
||||||
|
The FastCDC parameters are fixed and public. `chunk_size` sets the average chunk
|
||||||
|
size. No chunk is larger than four times this size. Only a file's last chunk can
|
||||||
|
be smaller than a quarter of it. With the default of 10 MB, those limits are 40
|
||||||
|
MB and 2.5 MB. A file of 2.5 MB or less is one chunk.
|
||||||
|
|
||||||
|
At the default `blob_size_limit` of 10 GB, a blob holds hundreds of chunks. The
|
||||||
|
size of the blob then does not show the size of any one chunk. A limit close to
|
||||||
|
`chunk_size` begins to show chunk sizes.
|
||||||
|
|
||||||
|
### Decrypted data on the local disk
|
||||||
|
|
||||||
|
These commands write unencrypted data to the temporary directory:
|
||||||
|
|
||||||
|
- `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/`. There
|
||||||
|
is no limit on their total size.
|
||||||
|
- `snapshot restore` writes the decrypted database to
|
||||||
|
`vaultik-restore-*/snapshot.db`.
|
||||||
|
- `snapshot verify --deep` writes the decrypted database to
|
||||||
|
`vaultik-verify-*/snapshot.db`.
|
||||||
|
- `snapshot create` writes a copy of the local index to
|
||||||
|
`vaultik-snapshot-*/snapshot.db`.
|
||||||
|
|
||||||
|
These files are readable only by their owner, and are removed when the command
|
||||||
|
ends. A `kill -9` or a power loss leaves them behind. Delete any leftover
|
||||||
|
`vaultik-*` entry in the temporary directory yourself.
|
||||||
|
|
||||||
|
Trust the temporary directory as much as the directory you restore into.
|
||||||
|
|
||||||
|
### Commands need different permissions
|
||||||
|
|
||||||
|
`snapshot create` needs only permission to write objects. Other commands need
|
||||||
|
more:
|
||||||
|
|
||||||
|
- `snapshot list`, `snapshot verify`, `snapshot restore` and `remote info` list
|
||||||
|
and read.
|
||||||
|
- `snapshot purge`, `snapshot remove` and `remote nuke` list and delete.
|
||||||
|
- `prune` lists, reads and deletes.
|
||||||
|
|
||||||
|
`snapshot create --prune` runs a prune on the backed-up machine. That machine
|
||||||
|
then holds credentials that can delete its own backups. To keep it to
|
||||||
|
write-only credentials, run `prune` from a separate, trusted machine.
|
||||||
|
|
||||||
|
### New recipients cannot read old data
|
||||||
|
|
||||||
|
Changing `age_recipients` does not re-encrypt what is already stored. A chunk
|
||||||
|
that is already stored is not written again, so it stays encrypted to the
|
||||||
|
earlier recipients.
|
||||||
|
|
||||||
|
A new snapshot that uses such chunks cannot be restored with a new recipient's
|
||||||
|
key alone. To make everything readable by a new key, run
|
||||||
|
`vaultik database delete` and take a full backup to a new destination.
|
||||||
|
|
||||||
|
### X25519 recipients only
|
||||||
|
|
||||||
|
`vaultik` accepts age X25519 public keys only. It rejects age SSH and plugin
|
||||||
|
recipients.
|
||||||
|
|
||||||
|
The encrypted data is held by a third party for a long time. There is no second
|
||||||
|
kind of recipient to fall back on if X25519 is broken, and no post-quantum
|
||||||
|
option.
|
||||||
|
|
||||||
|
## Order of writes
|
||||||
|
|
||||||
|
A backup writes in a fixed order:
|
||||||
|
|
||||||
|
- the marker file
|
||||||
|
- the blobs
|
||||||
|
- `db.zst.age`
|
||||||
|
- `manifest.json.zst`
|
||||||
|
|
||||||
|
A snapshot's metadata is written only after all its blobs are uploaded. The
|
||||||
|
manifest is written last. A directory under `metadata/` that has no manifest
|
||||||
|
therefore belongs to a backup that did not finish.
|
||||||
|
|
||||||
|
The local index marks a snapshot complete only after both metadata files are
|
||||||
|
uploaded. A backup that stops during that upload leaves the snapshot
|
||||||
|
unfinished. The next backup removes it from the local index and stores its data
|
||||||
|
again.
|
||||||
|
|
||||||
|
One short gap remains. A backup can stop after the upload and before the
|
||||||
|
snapshot is marked complete. The destination then holds a snapshot that can be
|
||||||
|
restored, and the local index does not list it. `snapshot list` shows it as
|
||||||
|
remote only.
|
||||||
|
|
||||||
|
An upload that is cut off leaves no partial object under the final name on S3
|
||||||
|
or on a local directory. The same holds for an rclone remote that can move
|
||||||
|
objects. An rclone remote that cannot move objects can keep a partial object.
|
||||||
|
|
||||||
|
## Pruning
|
||||||
|
|
||||||
|
`prune` deletes two kinds of object at the destination:
|
||||||
|
|
||||||
|
- a blob that no manifest lists
|
||||||
|
- a directory under `metadata/` that has no manifest
|
||||||
|
|
||||||
|
It reads the manifests, which are not encrypted, so it needs no private key.
|
||||||
|
|
||||||
|
These rules keep it from deleting data that is in use:
|
||||||
|
|
||||||
|
- It keeps every blob that any manifest lists.
|
||||||
|
- It deletes nothing when any manifest cannot be downloaded or decoded.
|
||||||
|
- It deletes a blob only once the blob is older than `prune_grace_period`.
|
||||||
|
- It deletes a directory only once every object in it is that old.
|
||||||
|
- It keeps a directory whose snapshot is in the local index.
|
||||||
|
- It leaves alone a directory whose name is not a remote key.
|
||||||
|
|
||||||
|
`prune_grace_period` defaults to `24h`. It protects a backup that is still
|
||||||
|
running. Such a backup has uploaded blobs and perhaps `db.zst.age`, but not yet
|
||||||
|
its manifest. A prune from this machine or another keeps those objects, as long
|
||||||
|
as no backup takes longer than the grace period.
|
||||||
|
|
||||||
|
The grace period does not protect one case. A backup does not upload a blob
|
||||||
|
that is already at the destination, so that blob keeps the time of its first
|
||||||
|
upload. The blob can be left over from an earlier backup that stopped before
|
||||||
|
its manifest. A prune that runs before the new manifest is written can then
|
||||||
|
delete that blob. The new snapshot cannot be restored afterwards.
|
||||||
|
|
||||||
|
To avoid this case, give each machine and each user its own destination.
|
||||||
|
|
||||||
|
`remote nuke` applies none of these rules.
|
||||||
|
|
||||||
|
## What a restore needs
|
||||||
|
|
||||||
|
- the age private key
|
||||||
|
- the snapshot's `db.zst.age`
|
||||||
|
- the blobs that hold the files to restore
|
||||||
|
|
||||||
|
A restore does not read the manifest and does not need the local index.
|
||||||
|
|
||||||
|
It works in these steps:
|
||||||
|
|
||||||
|
- It downloads and decrypts `db.zst.age`.
|
||||||
|
- It checks that the database is the one for the snapshot that was asked for.
|
||||||
|
- It downloads and decrypts the blobs it needs. It checks each blob against its
|
||||||
|
name.
|
||||||
|
- It writes each file from its chunks.
|
||||||
|
- It sets each file's permissions and times, and its owner when it runs as
|
||||||
|
root.
|
||||||
@@ -1,28 +1,301 @@
|
|||||||
module git.eeqj.de/sneak/vaultik
|
module sneak.berlin/go/vaultik
|
||||||
|
|
||||||
go 1.24.4
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.9.1
|
filippo.io/age v1.2.1
|
||||||
|
git.eeqj.de/sneak/smartconfig v1.0.0
|
||||||
|
github.com/adrg/xdg v0.5.3
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.6
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.17
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0
|
||||||
|
github.com/aws/smithy-go v1.23.2
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/gobwas/glob v0.2.3
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668
|
||||||
|
github.com/klauspost/compress v1.18.1
|
||||||
|
github.com/rclone/rclone v1.72.1
|
||||||
|
github.com/spf13/afero v1.15.0
|
||||||
|
github.com/spf13/cobra v1.10.1
|
||||||
|
github.com/spf13/pflag v1.0.10
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
go.uber.org/dig v1.19.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
|
golang.org/x/sync v0.18.0
|
||||||
|
golang.org/x/sys v0.38.0
|
||||||
|
golang.org/x/term v0.37.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.38.0
|
modernc.org/sqlite v1.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
cloud.google.com/go/auth v0.17.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
|
cloud.google.com/go/iam v1.5.2 // indirect
|
||||||
|
cloud.google.com/go/secretmanager v1.15.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||||
|
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
|
||||||
|
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
|
||||||
|
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
|
||||||
|
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
|
||||||
|
github.com/ProtonMail/go-srp v0.0.7 // indirect
|
||||||
|
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||||
|
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
|
||||||
|
github.com/abbot/go-http-auth v0.4.0 // indirect
|
||||||
|
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
|
||||||
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go v1.44.256 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/boombuler/barcode v1.1.0 // indirect
|
||||||
|
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||||
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||||
|
github.com/buengese/sgzip v0.1.1 // indirect
|
||||||
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
|
github.com/calebcase/tmpfile v1.0.3 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
|
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
|
||||||
|
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
|
||||||
|
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
|
||||||
|
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
|
||||||
|
github.com/coreos/go-semver v0.3.1 // indirect
|
||||||
|
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
|
||||||
|
github.com/creasty/defaults v1.8.0 // indirect
|
||||||
|
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||||
|
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
|
||||||
|
github.com/ebitengine/purego v0.9.1 // indirect
|
||||||
|
github.com/emersion/go-message v0.18.2 // indirect
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/flynn/noise v1.1.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
|
github.com/geoffgarside/ber v1.2.0 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
|
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-openapi/errors v0.22.4 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/strfmt v0.25.0 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/gofrs/flock v0.13.0 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/btree v1.1.3 // indirect
|
||||||
|
github.com/google/gnostic-models v0.6.9 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
|
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||||
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||||
|
github.com/hashicorp/consul/api v1.32.1 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||||
|
github.com/hashicorp/serf v0.10.1 // indirect
|
||||||
|
github.com/hashicorp/vault/api v1.20.0 // indirect
|
||||||
|
github.com/henrybear327/Proton-API-Bridge v1.0.0 // indirect
|
||||||
|
github.com/henrybear327/go-proton-api v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||||
|
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||||
|
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||||
|
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
|
||||||
|
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
|
||||||
|
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
|
||||||
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
|
github.com/lanrat/extsort v1.4.2 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lpar/date v1.0.0 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
|
github.com/mailru/easyjson v0.9.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/ncw/swift/v2 v2.0.5 // indirect
|
||||||
|
github.com/oklog/ulid v1.3.1 // indirect
|
||||||
|
github.com/onsi/ginkgo/v2 v2.23.3 // indirect
|
||||||
|
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
|
||||||
|
github.com/panjf2000/ants/v2 v2.11.3 // indirect
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||||
|
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
|
||||||
|
github.com/peterh/liner v1.2.2 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pkg/sftp v1.13.10 // indirect
|
||||||
|
github.com/pkg/xattr v0.4.12 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.2 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
|
||||||
|
github.com/relvacode/iso8601 v1.7.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/rfjakob/eme v1.1.2 // indirect
|
||||||
go.uber.org/dig v1.19.0 // indirect
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
github.com/samber/lo v1.52.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||||
|
github.com/smarty/assertions v1.16.0 // indirect
|
||||||
|
github.com/sony/gobreaker v1.0.0 // indirect
|
||||||
|
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
|
||||||
|
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
github.com/unknwon/goconfig v1.0.0 // indirect
|
||||||
|
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
|
github.com/zeebo/errs v1.4.0 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.etcd.io/bbolt v1.4.3 // indirect
|
||||||
|
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
||||||
|
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
||||||
|
go.etcd.io/etcd/client/v3 v3.6.2 // indirect
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
golang.org/x/net v0.47.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
|
google.golang.org/api v0.255.0 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||||
|
google.golang.org/grpc v1.76.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
|
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
k8s.io/api v0.33.3 // indirect
|
||||||
|
k8s.io/apimachinery v0.33.3 // indirect
|
||||||
|
k8s.io/client-go v0.33.3 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
|
||||||
|
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||||
modernc.org/libc v1.65.10 // indirect
|
modernc.org/libc v1.65.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
moul.io/http2curl/v2 v2.3.0 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||||
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
|
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
|
||||||
|
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
|
||||||
|
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
|
||||||
|
storj.io/infectious v0.0.2 // indirect
|
||||||
|
storj.io/picobuf v0.0.4 // indirect
|
||||||
|
storj.io/uplink v1.13.1 // indirect
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package blob
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrBlobSizeLimitExceeded is returned when adding a chunk would exceed
|
||||||
|
// the blob size limit.
|
||||||
|
var ErrBlobSizeLimitExceeded = errors.New("adding chunk would exceed blob size limit")
|
||||||
|
|
||||||
|
// ErrNoRecipients is returned when a Packer is created without any age
|
||||||
|
// recipients; blobs must always be encrypted.
|
||||||
|
var ErrNoRecipients = errors.New("recipients are required - blobs must be encrypted")
|
||||||
|
|
||||||
|
// ErrInvalidMaxBlobSize is returned when the configured maximum blob size
|
||||||
|
// is zero or negative.
|
||||||
|
var ErrInvalidMaxBlobSize = errors.New("max blob size must be positive")
|
||||||
|
|
||||||
|
// ErrNoFilesystem is returned when a Packer is created without a filesystem
|
||||||
|
// for temporary files.
|
||||||
|
var ErrNoFilesystem = errors.New("filesystem is required")
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
// Package blob handles the creation of blobs - the final storage units for Vaultik.
|
||||||
|
// A blob is a large file (up to 10GB) containing many compressed and encrypted chunks
|
||||||
|
// from multiple source files. Blobs are content-addressed: a blob's filename is
|
||||||
|
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the concatenated
|
||||||
|
// chunk data before compression and encryption, not from the stored bytes. See
|
||||||
|
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
||||||
|
//
|
||||||
|
// The blob creation process:
|
||||||
|
// 1. Chunks are accumulated from multiple files
|
||||||
|
// 2. Each chunk's uncompressed bytes are fed to a running SHA-256 and, in the same
|
||||||
|
// pass, compressed with zstd and encrypted with age into the temp file
|
||||||
|
// 3. On finalize, the name is the double SHA-256 of that uncompressed content
|
||||||
|
// 4. The blob is uploaded to S3 using the name as the filename
|
||||||
|
//
|
||||||
|
// This design optimizes storage efficiency by batching many small chunks into
|
||||||
|
// larger blobs, reducing the number of S3 operations and associated costs.
|
||||||
|
package blob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a callback function invoked when a blob is finalized and
|
||||||
|
// ready for upload. The handler receives a WithReader containing the
|
||||||
|
// blob metadata and a reader for the compressed and encrypted blob content.
|
||||||
|
// The handler is responsible for uploading the blob to storage and cleaning
|
||||||
|
// up any temporary files.
|
||||||
|
type Handler func(blob *WithReader) error
|
||||||
|
|
||||||
|
// PackerConfig holds configuration for creating a Packer.
|
||||||
|
// All fields except BlobHandler are required.
|
||||||
|
type PackerConfig struct {
|
||||||
|
// MaxBlobSize is the maximum size of a blob before forcing finalization.
|
||||||
|
MaxBlobSize int64
|
||||||
|
// CompressionLevel is the zstd level (1-19, higher = better compression).
|
||||||
|
CompressionLevel int
|
||||||
|
// Recipients holds the age recipients for encryption.
|
||||||
|
Recipients []string
|
||||||
|
// Repositories provides database access for tracking blob metadata.
|
||||||
|
Repositories *database.Repositories
|
||||||
|
// BlobHandler is an optional callback when a blob is ready for upload.
|
||||||
|
BlobHandler Handler
|
||||||
|
// Fs is the filesystem used for temporary files.
|
||||||
|
Fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingChunk represents a chunk waiting to be inserted into the database.
|
||||||
|
type PendingChunk struct {
|
||||||
|
Hash string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packer accumulates chunks and packs them into blobs.
|
||||||
|
// It handles compression, encryption, and coordination with the database
|
||||||
|
// to track blob metadata. Packer is thread-safe.
|
||||||
|
type Packer struct {
|
||||||
|
maxBlobSize int64
|
||||||
|
compressionLevel int
|
||||||
|
recipients []string // Age recipients for encryption
|
||||||
|
blobHandler Handler // Called when blob is ready
|
||||||
|
repos *database.Repositories // For creating blob records
|
||||||
|
fs afero.Fs // Filesystem for temporary files
|
||||||
|
|
||||||
|
// Mutex for thread-safe blob creation
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Current blob being packed
|
||||||
|
currentBlob *blobInProgress
|
||||||
|
finishedBlobs []*FinishedBlob // Only used if no handler provided
|
||||||
|
|
||||||
|
// Pending chunks to be inserted when blob finalizes
|
||||||
|
pendingChunks []PendingChunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// blobInProgress represents a blob being assembled
|
||||||
|
type blobInProgress struct {
|
||||||
|
id string // UUID of the blob
|
||||||
|
chunks []*chunkInfo // Track chunk metadata
|
||||||
|
chunkSet map[string]bool // Track unique chunks in this blob
|
||||||
|
tempFile afero.File // Temporary file for encrypted compressed data
|
||||||
|
writer *blobgen.Writer // Unified compression/encryption/hashing writer
|
||||||
|
startTime time.Time
|
||||||
|
size int64 // Current uncompressed size
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkRef represents a chunk to be added to a blob.
|
||||||
|
// The Hash is the content-addressed identifier (SHA256) of the chunk,
|
||||||
|
// and Data contains the raw chunk bytes. After adding to a blob,
|
||||||
|
// the Data can be safely discarded as it's written to the blob immediately.
|
||||||
|
type ChunkRef struct {
|
||||||
|
Hash string // SHA256 hash of the chunk data
|
||||||
|
Data []byte // Raw chunk content
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkInfo tracks chunk metadata in a blob
|
||||||
|
type chunkInfo struct {
|
||||||
|
Hash string
|
||||||
|
Offset int64
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishedBlob represents a completed blob ready for storage
|
||||||
|
type FinishedBlob struct {
|
||||||
|
ID string
|
||||||
|
Hash string
|
||||||
|
Data []byte // Compressed data
|
||||||
|
Chunks []*ChunkPosition
|
||||||
|
CreatedTS time.Time
|
||||||
|
Uncompressed int64
|
||||||
|
Compressed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkPosition represents a chunk's position within a blob
|
||||||
|
type ChunkPosition struct {
|
||||||
|
ChunkHash string
|
||||||
|
Offset int64
|
||||||
|
Length int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithReader wraps a FinishedBlob with its data reader
|
||||||
|
type WithReader struct {
|
||||||
|
*FinishedBlob
|
||||||
|
|
||||||
|
Reader io.ReadSeeker
|
||||||
|
TempFile afero.File // Optional, only set for disk-based blobs
|
||||||
|
InsertedChunkHashes []string // Chunk hashes that were inserted to DB with this blob
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPacker creates a new blob packer that accumulates chunks into blobs.
|
||||||
|
// The packer will automatically finalize blobs when they reach MaxBlobSize.
|
||||||
|
// Returns an error if required configuration fields are missing or invalid.
|
||||||
|
func NewPacker(cfg PackerConfig) (*Packer, error) {
|
||||||
|
if len(cfg.Recipients) == 0 {
|
||||||
|
return nil, ErrNoRecipients
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxBlobSize <= 0 {
|
||||||
|
return nil, ErrInvalidMaxBlobSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Fs == nil {
|
||||||
|
return nil, ErrNoFilesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Packer{
|
||||||
|
maxBlobSize: cfg.MaxBlobSize,
|
||||||
|
compressionLevel: cfg.CompressionLevel,
|
||||||
|
recipients: cfg.Recipients,
|
||||||
|
blobHandler: cfg.BlobHandler,
|
||||||
|
repos: cfg.Repositories,
|
||||||
|
fs: cfg.Fs,
|
||||||
|
finishedBlobs: make([]*FinishedBlob, 0),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBlobHandler sets the handler to be called when a blob is finalized.
|
||||||
|
// The handler is responsible for uploading the blob to storage.
|
||||||
|
// If no handler is set, finalized blobs are stored in memory and can be
|
||||||
|
// retrieved with GetFinishedBlobs().
|
||||||
|
func (p *Packer) SetBlobHandler(handler Handler) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.blobHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPendingChunk queues a chunk to be inserted into the database when the
|
||||||
|
// current blob is finalized. This batches chunk inserts to reduce transaction
|
||||||
|
// overhead. Thread-safe.
|
||||||
|
func (p *Packer) AddPendingChunk(hash string, size int64) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.pendingChunks = append(p.pendingChunks, PendingChunk{Hash: hash, Size: size})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddChunk adds a chunk to the current blob being packed.
|
||||||
|
// If adding the chunk would exceed MaxBlobSize, returns ErrBlobSizeLimitExceeded.
|
||||||
|
// In this case, the caller should finalize the current blob and retry.
|
||||||
|
// The chunk data is written immediately and can be garbage collected after this call.
|
||||||
|
// Thread-safe.
|
||||||
|
func (p *Packer) AddChunk(ctx context.Context, chunk *ChunkRef) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Initialize new blob if needed
|
||||||
|
if p.currentBlob == nil {
|
||||||
|
err := p.startNewBlob(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("starting new blob: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding this chunk would exceed blob size limit
|
||||||
|
// Use conservative estimate: assume no compression
|
||||||
|
// Skip size check if chunk already exists in blob
|
||||||
|
if !p.currentBlob.chunkSet[chunk.Hash] {
|
||||||
|
currentSize := p.currentBlob.size
|
||||||
|
newSize := currentSize + int64(len(chunk.Data))
|
||||||
|
|
||||||
|
if newSize > p.maxBlobSize && len(p.currentBlob.chunks) > 0 {
|
||||||
|
// Return error indicating size limit would be exceeded
|
||||||
|
return ErrBlobSizeLimitExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add chunk to current blob
|
||||||
|
err := p.addChunkToCurrentBlob(chunk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush finalizes any in-progress blob, compressing, encrypting, and hashing it.
|
||||||
|
// This should be called after all chunks have been added to ensure no data is lost.
|
||||||
|
// If a BlobHandler is set, it will be called with the finalized blob.
|
||||||
|
// Thread-safe.
|
||||||
|
func (p *Packer) Flush(ctx context.Context) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.currentBlob != nil && len(p.currentBlob.chunks) > 0 {
|
||||||
|
err := p.finalizeCurrentBlob(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finalizing blob: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscardCurrentBlob closes and removes the temp file of the blob being
|
||||||
|
// packed, if any, and drops that blob. A caller that stops before Flush
|
||||||
|
// must call it, or the temp file is left behind. Thread-safe.
|
||||||
|
func (p *Packer) DiscardCurrentBlob() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.cleanupTempFile()
|
||||||
|
p.currentBlob = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinalizeBlob finalizes the current blob being assembled.
|
||||||
|
// This compresses the accumulated chunks, encrypts the result, and computes
|
||||||
|
// the content-addressed hash. The finalized blob is either passed to the
|
||||||
|
// BlobHandler (if set) or stored internally.
|
||||||
|
// Caller must handle retrying any chunk that triggered size limit exceeded.
|
||||||
|
// Not thread-safe - caller must hold the lock.
|
||||||
|
func (p *Packer) FinalizeBlob(ctx context.Context) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.currentBlob == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.finalizeCurrentBlob(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFinishedBlobs returns all completed blobs and clears the internal list.
|
||||||
|
// This is only used when no BlobHandler is set. After calling this method,
|
||||||
|
// the caller is responsible for uploading the blobs to storage.
|
||||||
|
// Thread-safe.
|
||||||
|
func (p *Packer) GetFinishedBlobs() []*FinishedBlob {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
blobs := p.finishedBlobs
|
||||||
|
p.finishedBlobs = make([]*FinishedBlob, 0)
|
||||||
|
|
||||||
|
return blobs
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackChunks is a convenience method to pack multiple chunks at once.
|
||||||
|
func (p *Packer) PackChunks(ctx context.Context, chunks []*ChunkRef) error {
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
err := p.AddChunk(ctx, chunk)
|
||||||
|
if errors.Is(err, ErrBlobSizeLimitExceeded) {
|
||||||
|
// Finalize current blob and retry
|
||||||
|
err = p.FinalizeBlob(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finalizing blob before retry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry the chunk
|
||||||
|
err = p.AddChunk(ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"adding chunk %s after finalize: %w", chunk.Hash, err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("adding chunk %s: %w", chunk.Hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Flush(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startNewBlob initializes a new blob (must be called with lock held)
|
||||||
|
func (p *Packer) startNewBlob(ctx context.Context) error {
|
||||||
|
// Generate UUID for the blob
|
||||||
|
blobID := uuid.New().String()
|
||||||
|
|
||||||
|
// Create blob record in database
|
||||||
|
if p.repos != nil {
|
||||||
|
blobIDTyped, err := types.ParseBlobID(blobID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing blob ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := &database.Blob{
|
||||||
|
ID: blobIDTyped,
|
||||||
|
// Temporary placeholder hash until finalized.
|
||||||
|
Hash: types.BlobHash("temp-placeholder-" + blobID),
|
||||||
|
CreatedTS: time.Now().UTC(),
|
||||||
|
FinishedTS: nil,
|
||||||
|
UncompressedSize: 0,
|
||||||
|
CompressedSize: 0,
|
||||||
|
UploadedTS: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.repos.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(txCtx context.Context, tx *sql.Tx) error {
|
||||||
|
return p.repos.Blobs.Create(txCtx, tx, blob)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating blob record: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary file
|
||||||
|
tempFile, err := afero.TempFile(p.fs, "", "vaultik-blob-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blobgen writer for unified compression/encryption/hashing
|
||||||
|
writer, err := blobgen.NewWriter(tempFile, p.compressionLevel, p.recipients)
|
||||||
|
if err != nil {
|
||||||
|
_ = tempFile.Close()
|
||||||
|
_ = p.fs.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
return fmt.Errorf("creating blobgen writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.currentBlob = &blobInProgress{
|
||||||
|
id: blobID,
|
||||||
|
chunks: make([]*chunkInfo, 0),
|
||||||
|
chunkSet: make(map[string]bool),
|
||||||
|
startTime: time.Now().UTC(),
|
||||||
|
tempFile: tempFile,
|
||||||
|
writer: writer,
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Created new blob container",
|
||||||
|
"blob_id", blobID, "temp_file", tempFile.Name())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addChunkToCurrentBlob adds a chunk to the current blob (must be called
|
||||||
|
// with lock held).
|
||||||
|
func (p *Packer) addChunkToCurrentBlob(chunk *ChunkRef) error {
|
||||||
|
// Skip if chunk already in current blob
|
||||||
|
if p.currentBlob.chunkSet[chunk.Hash] {
|
||||||
|
log.Debug("Skipping duplicate chunk already in current blob",
|
||||||
|
"chunk_hash", chunk.Hash)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track offset before writing
|
||||||
|
offset := p.currentBlob.size
|
||||||
|
|
||||||
|
// Write to the blobgen writer (compression -> encryption -> disk)
|
||||||
|
_, err := p.currentBlob.writer.Write(chunk.Data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing to blob stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track chunk info
|
||||||
|
chunkSize := int64(len(chunk.Data))
|
||||||
|
chunkInfo := &chunkInfo{
|
||||||
|
Hash: chunk.Hash,
|
||||||
|
Offset: offset,
|
||||||
|
Size: chunkSize,
|
||||||
|
}
|
||||||
|
p.currentBlob.chunks = append(p.currentBlob.chunks, chunkInfo)
|
||||||
|
p.currentBlob.chunkSet[chunk.Hash] = true
|
||||||
|
|
||||||
|
// Note: blob_chunk records are inserted in batch when blob is finalized
|
||||||
|
// to reduce transaction overhead. The chunk info is already stored in
|
||||||
|
// p.currentBlob.chunks for later insertion.
|
||||||
|
|
||||||
|
// Update total size
|
||||||
|
p.currentBlob.size += chunkSize
|
||||||
|
|
||||||
|
log.Debug("Added chunk to blob container",
|
||||||
|
"blob_id", p.currentBlob.id,
|
||||||
|
"chunk_hash", chunk.Hash,
|
||||||
|
"chunk_size", len(chunk.Data),
|
||||||
|
"offset", offset,
|
||||||
|
"blob_chunks", len(p.currentBlob.chunks),
|
||||||
|
"uncompressed_size", p.currentBlob.size)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeCurrentBlob completes the current blob (must be called with lock held)
|
||||||
|
func (p *Packer) finalizeCurrentBlob(ctx context.Context) error {
|
||||||
|
if p.currentBlob == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
blobHash, finalSize, err := p.closeBlobWriter()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkRefs := p.buildChunkRefs()
|
||||||
|
|
||||||
|
chunksToInsert := p.pendingChunks
|
||||||
|
p.pendingChunks = nil
|
||||||
|
|
||||||
|
err = p.commitBlobToDatabase(ctx, blobHash, finalSize, chunksToInsert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
finished := &FinishedBlob{
|
||||||
|
ID: p.currentBlob.id,
|
||||||
|
Hash: blobHash,
|
||||||
|
Chunks: chunkRefs,
|
||||||
|
CreatedTS: p.currentBlob.startTime,
|
||||||
|
Uncompressed: p.currentBlob.size,
|
||||||
|
Compressed: finalSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
compressionRatio := float64(finished.Compressed) / float64(finished.Uncompressed)
|
||||||
|
log.Info("Finalized blob (compressed and encrypted)",
|
||||||
|
"hash", blobHash, "chunks", len(chunkRefs),
|
||||||
|
"uncompressed", finished.Uncompressed, "compressed", finished.Compressed,
|
||||||
|
"ratio", fmt.Sprintf("%.2f", compressionRatio),
|
||||||
|
"duration", time.Since(p.currentBlob.startTime))
|
||||||
|
|
||||||
|
var insertedChunkHashes []string
|
||||||
|
for _, chunk := range chunksToInsert {
|
||||||
|
insertedChunkHashes = append(insertedChunkHashes, chunk.Hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.deliverFinishedBlob(finished, insertedChunkHashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeBlobWriter closes the writer, syncs to disk, and returns the blob
|
||||||
|
// hash and final size.
|
||||||
|
func (p *Packer) closeBlobWriter() (string, int64, error) {
|
||||||
|
err := p.currentBlob.writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return "", 0, fmt.Errorf("closing blobgen writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.currentBlob.tempFile.Sync()
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return "", 0, fmt.Errorf("syncing temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalSize, err := p.currentBlob.tempFile.Seek(0, io.SeekCurrent)
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return "", 0, fmt.Errorf("getting file size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = p.currentBlob.tempFile.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return "", 0, fmt.Errorf("seeking to start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalHash := p.currentBlob.writer.ContentID()
|
||||||
|
|
||||||
|
return hex.EncodeToString(finalHash), finalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChunkRefs creates ChunkPosition entries from the current blob's chunks
|
||||||
|
func (p *Packer) buildChunkRefs() []*ChunkPosition {
|
||||||
|
refs := make([]*ChunkPosition, 0, len(p.currentBlob.chunks))
|
||||||
|
for _, chunk := range p.currentBlob.chunks {
|
||||||
|
refs = append(refs, &ChunkPosition{
|
||||||
|
ChunkHash: chunk.Hash, Offset: chunk.Offset, Length: chunk.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitBlobToDatabase inserts pending chunks, blob_chunks, and updates the blob record
|
||||||
|
func (p *Packer) commitBlobToDatabase(
|
||||||
|
ctx context.Context,
|
||||||
|
blobHash string, finalSize int64, chunksToInsert []PendingChunk,
|
||||||
|
) error {
|
||||||
|
if p.repos == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
blobIDTyped, parseErr := types.ParseBlobID(p.currentBlob.id)
|
||||||
|
if parseErr != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return fmt.Errorf("parsing blob ID: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.repos.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(txCtx context.Context, tx *sql.Tx) error {
|
||||||
|
return p.insertBlobRecords(txCtx, tx, blobIDTyped, blobHash,
|
||||||
|
finalSize, chunksToInsert)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return fmt.Errorf("finalizing blob transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Committed blob transaction",
|
||||||
|
"chunks_inserted", len(chunksToInsert),
|
||||||
|
"blob_chunks_inserted", len(p.currentBlob.chunks))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertBlobRecords inserts pending chunks and blob_chunk rows, then marks
|
||||||
|
// the blob finished, all within the supplied transaction.
|
||||||
|
func (p *Packer) insertBlobRecords(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
blobIDTyped types.BlobID,
|
||||||
|
blobHash string,
|
||||||
|
finalSize int64,
|
||||||
|
chunksToInsert []PendingChunk,
|
||||||
|
) error {
|
||||||
|
for _, chunk := range chunksToInsert {
|
||||||
|
dbChunk := &database.Chunk{
|
||||||
|
ChunkHash: types.ChunkHash(chunk.Hash), Size: chunk.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.repos.Chunks.Create(ctx, tx, dbChunk)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating chunk: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range p.currentBlob.chunks {
|
||||||
|
blobChunk := &database.BlobChunk{
|
||||||
|
BlobID: blobIDTyped, ChunkHash: types.ChunkHash(chunk.Hash),
|
||||||
|
Offset: chunk.Offset, Length: chunk.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.repos.BlobChunks.Create(ctx, tx, blobChunk)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating blob_chunk: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.repos.Blobs.UpdateFinished(ctx, tx, p.currentBlob.id, blobHash,
|
||||||
|
p.currentBlob.size, finalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverFinishedBlob passes the blob to the handler or stores it internally
|
||||||
|
func (p *Packer) deliverFinishedBlob(
|
||||||
|
finished *FinishedBlob, insertedChunkHashes []string,
|
||||||
|
) error {
|
||||||
|
if p.blobHandler != nil {
|
||||||
|
_, err := p.currentBlob.tempFile.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return fmt.Errorf("seeking for handler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobWithReader := &WithReader{
|
||||||
|
FinishedBlob: finished,
|
||||||
|
Reader: p.currentBlob.tempFile,
|
||||||
|
TempFile: p.currentBlob.tempFile,
|
||||||
|
InsertedChunkHashes: insertedChunkHashes,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.blobHandler(blobWithReader)
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return fmt.Errorf("blob handler failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.currentBlob = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No handler - read data for legacy behavior
|
||||||
|
log.Debug("No blob handler callback configured", "blob_hash", finished.Hash[:8]+"...")
|
||||||
|
|
||||||
|
_, err := p.currentBlob.tempFile.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return fmt.Errorf("seeking to read data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(p.currentBlob.tempFile)
|
||||||
|
if err != nil {
|
||||||
|
p.cleanupTempFile()
|
||||||
|
|
||||||
|
return fmt.Errorf("reading blob data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finished.Data = data
|
||||||
|
p.finishedBlobs = append(p.finishedBlobs, finished)
|
||||||
|
p.cleanupTempFile()
|
||||||
|
p.currentBlob = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTempFile removes the temporary file
|
||||||
|
func (p *Packer) cleanupTempFile() {
|
||||||
|
if p.currentBlob != nil && p.currentBlob.tempFile != nil {
|
||||||
|
name := p.currentBlob.tempFile.Name()
|
||||||
|
_ = p.currentBlob.tempFile.Close()
|
||||||
|
_ = p.fs.Remove(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package blob_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blob"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Test key from test/insecure-integration-test.key
|
||||||
|
testPrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7A" +
|
||||||
|
"PHXA2QS2NJA5"
|
||||||
|
testPublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||||
|
|
||||||
|
defaultMaxBlobSize = 10 * 1024 * 1024 // 10MB
|
||||||
|
testChunkSize = 1000
|
||||||
|
testChunkCount = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTestIdentity parses the fixed test age identity.
|
||||||
|
func parseTestIdentity(t *testing.T) *age.X25519Identity {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
identity, err := age.ParseX25519Identity(testPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse test identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestPacker creates a test database and a Packer backed by it.
|
||||||
|
func newTestPacker(
|
||||||
|
t *testing.T, maxBlobSize int64,
|
||||||
|
) (*database.Repositories, *blob.Packer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
packer, err := blob.NewPacker(blob.PackerConfig{
|
||||||
|
MaxBlobSize: maxBlobSize,
|
||||||
|
CompressionLevel: 3,
|
||||||
|
Recipients: []string{testPublicKey},
|
||||||
|
Repositories: repos,
|
||||||
|
Fs: afero.NewMemMapFs(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create packer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return repos, packer
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeChunk creates a ChunkRef for data and registers the chunk in the
|
||||||
|
// database.
|
||||||
|
func makeChunk(
|
||||||
|
t *testing.T, repos *database.Repositories, data []byte,
|
||||||
|
) *blob.ChunkRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
hashStr := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
dbChunk := &database.Chunk{
|
||||||
|
ChunkHash: types.ChunkHash(hashStr),
|
||||||
|
Size: int64(len(data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.WithTx(
|
||||||
|
context.Background(),
|
||||||
|
func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return repos.Chunks.Create(ctx, tx, dbChunk)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create chunk in db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &blob.ChunkRef{
|
||||||
|
Hash: hashStr,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptAndDecompress reverses the blob pipeline: age decrypt, then zstd
|
||||||
|
// decompress.
|
||||||
|
func decryptAndDecompress(
|
||||||
|
t *testing.T, blobData []byte, identity *age.X25519Identity,
|
||||||
|
) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
decrypted, err := age.Decrypt(bytes.NewReader(blobData), identity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decrypt blob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := zstd.NewReader(decrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create decompressor: %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
var decompressed bytes.Buffer
|
||||||
|
|
||||||
|
_, err = io.Copy(&decompressed, reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decompress: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decompressed.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackerSingleChunk(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
identity := parseTestIdentity(t)
|
||||||
|
repos, packer := newTestPacker(t, defaultMaxBlobSize)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data := []byte("Hello, World!")
|
||||||
|
chunk := makeChunk(t, repos, data)
|
||||||
|
|
||||||
|
err := packer.AddChunk(ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = packer.Flush(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := packer.GetFinishedBlobs()
|
||||||
|
if len(blobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 blob, got %d", len(blobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
finished := blobs[0]
|
||||||
|
if len(finished.Chunks) != 1 {
|
||||||
|
t.Errorf("expected 1 chunk in blob, got %d", len(finished.Chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Very small data may not compress well
|
||||||
|
t.Logf("Compression: %d -> %d bytes",
|
||||||
|
finished.Uncompressed, finished.Compressed)
|
||||||
|
|
||||||
|
decompressed := decryptAndDecompress(t, finished.Data, identity)
|
||||||
|
if !bytes.Equal(decompressed, data) {
|
||||||
|
t.Error("decompressed data doesn't match original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackerMultipleChunks(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
repos, packer := newTestPacker(t, defaultMaxBlobSize)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
chunks := make([]*blob.ChunkRef, testChunkCount)
|
||||||
|
for i := range testChunkCount {
|
||||||
|
data := bytes.Repeat([]byte{byte(i)}, testChunkSize)
|
||||||
|
chunks[i] = makeChunk(t, repos, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
err := packer.AddChunk(ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add chunk: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := packer.Flush(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := packer.GetFinishedBlobs()
|
||||||
|
if len(blobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 blob, got %d", len(blobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blobs[0].Chunks) != testChunkCount {
|
||||||
|
t.Errorf("expected %d chunks in blob, got %d",
|
||||||
|
testChunkCount, len(blobs[0].Chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify offsets are correct
|
||||||
|
expectedOffset := int64(0)
|
||||||
|
|
||||||
|
for i, chunkRef := range blobs[0].Chunks {
|
||||||
|
if chunkRef.Offset != expectedOffset {
|
||||||
|
t.Errorf("chunk %d: expected offset %d, got %d",
|
||||||
|
i, expectedOffset, chunkRef.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunkRef.Length != testChunkSize {
|
||||||
|
t.Errorf("chunk %d: expected length %d, got %d",
|
||||||
|
i, testChunkSize, chunkRef.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedOffset += chunkRef.Length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackerSizeLimit(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxBlobSize = 5000 // 5KB max, forces multiple blobs
|
||||||
|
maxBlobawoOverhead = 6000 // allow some overhead over the limit
|
||||||
|
)
|
||||||
|
|
||||||
|
repos, packer := newTestPacker(t, maxBlobSize)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
chunks := make([]*blob.ChunkRef, testChunkCount)
|
||||||
|
for i := range testChunkCount {
|
||||||
|
data := bytes.Repeat([]byte{byte(i)}, testChunkSize) // 1KB each
|
||||||
|
chunks[i] = makeChunk(t, repos, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobCount := 0
|
||||||
|
|
||||||
|
// Add chunks and handle size limit errors
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
err := packer.AddChunk(ctx, chunk)
|
||||||
|
if errors.Is(err, blob.ErrBlobSizeLimitExceeded) {
|
||||||
|
// Finalize current blob
|
||||||
|
err = packer.FinalizeBlob(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to finalize blob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobCount++
|
||||||
|
|
||||||
|
// Retry adding the chunk
|
||||||
|
err = packer.AddChunk(ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add chunk after finalize: %v", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatalf("failed to add chunk: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := packer.Flush(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := packer.GetFinishedBlobs()
|
||||||
|
|
||||||
|
totalBlobs := blobCount + len(blobs)
|
||||||
|
if totalBlobs < 2 {
|
||||||
|
t.Errorf("expected multiple blobs due to size limit, got %d", totalBlobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each blob respects size limit (approximately)
|
||||||
|
for _, finished := range blobs {
|
||||||
|
if finished.Compressed > maxBlobawoOverhead {
|
||||||
|
t.Errorf("blob size %d exceeds limit", finished.Compressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackerEncryption(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
identity := parseTestIdentity(t)
|
||||||
|
repos, packer := newTestPacker(t, defaultMaxBlobSize)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data := bytes.Repeat([]byte("Test data for encryption!"), 100)
|
||||||
|
chunk := makeChunk(t, repos, data)
|
||||||
|
|
||||||
|
err := packer.AddChunk(ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = packer.Flush(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := packer.GetFinishedBlobs()
|
||||||
|
if len(blobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 blob, got %d", len(blobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
decompressed := decryptAndDecompress(t, blobs[0].Data, identity)
|
||||||
|
if !bytes.Equal(decompressed, data) {
|
||||||
|
t.Error("decrypted and decompressed data doesn't match original")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ageChunkSize is age's STREAM plaintext chunk size (64 KiB); each encrypted
|
||||||
|
// chunk adds a 16-byte ChaCha20-Poly1305 tag.
|
||||||
|
const (
|
||||||
|
ageChunkSize = 64 * 1024
|
||||||
|
ageChunkTagSize = 16
|
||||||
|
ageSegmentSize = ageChunkSize + ageChunkTagSize
|
||||||
|
ageNonceSize = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeIdentity returns a fresh X25519 identity and its recipient string.
|
||||||
|
func makeIdentity(t *testing.T) (*age.X25519Identity, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
id, err := age.GenerateX25519Identity()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return id, id.Recipient().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomBytes returns n cryptographically random bytes, which do not compress
|
||||||
|
// so the encrypted payload spans multiple age segments.
|
||||||
|
func randomBytes(t *testing.T, n int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressibleBytes returns n bytes of a repeating pattern, which zstd packs
|
||||||
|
// down to a small payload.
|
||||||
|
func compressibleBytes(n int) []byte {
|
||||||
|
pattern := bytes.Repeat([]byte("compressible-"), n/13+1)
|
||||||
|
|
||||||
|
return pattern[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptBlob compresses, encrypts and returns a blob for plaintext at
|
||||||
|
// compression level 1.
|
||||||
|
func encryptBlob(t *testing.T, plaintext []byte, recipients ...string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
w, err := blobgen.NewWriter(&buf, 1, recipients)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = w.Write(plaintext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ageHeaderLen returns the byte length of blob's age header, i.e. the offset
|
||||||
|
// of the 16-byte payload nonce that follows it. The header ends with a MAC
|
||||||
|
// line "--- <mac>\n"; the nonce begins right after that newline.
|
||||||
|
func ageHeaderLen(t *testing.T, blob []byte) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
i := bytes.Index(blob, []byte("\n--- "))
|
||||||
|
require.GreaterOrEqual(t, i, 0, "age MAC footer line not found")
|
||||||
|
|
||||||
|
nl := bytes.IndexByte(blob[i+1:], '\n')
|
||||||
|
require.GreaterOrEqual(t, nl, 0, "newline ending MAC line not found")
|
||||||
|
|
||||||
|
return i + 1 + nl + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireBlobUnreadable asserts that data never decrypts to a plaintext with a
|
||||||
|
// nil error: either NewReader fails, or reading it does.
|
||||||
|
func requireBlobUnreadable(t *testing.T, data []byte, id age.Identity) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r, err := blobgen.NewReader(bytes.NewReader(data), id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.ReadAll(r)
|
||||||
|
_ = r.Close()
|
||||||
|
|
||||||
|
require.Error(t, err, "reading a damaged blob must fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// errFailWriter is returned by failAfterWriter once its byte limit is passed.
|
||||||
|
var errFailWriter = errors.New("destination write failed")
|
||||||
|
|
||||||
|
// failAfterWriter accepts writes until more than limit bytes have been sent,
|
||||||
|
// then fails every write. It models a destination that dies mid-blob.
|
||||||
|
type failAfterWriter struct {
|
||||||
|
limit int
|
||||||
|
written int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failAfterWriter) Write(p []byte) (int, error) {
|
||||||
|
f.written += len(p)
|
||||||
|
if f.written > f.limit {
|
||||||
|
return 0, errFailWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package blobgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrOutputTooLarge is returned by a reader from LimitReader once it has
|
||||||
|
// been asked for more than its limit. It bounds how far an untrusted
|
||||||
|
// compressed stream may expand, so a small, highly compressible object
|
||||||
|
// from the store cannot decompress without limit.
|
||||||
|
var ErrOutputTooLarge = errors.New("output exceeds size limit")
|
||||||
|
|
||||||
|
// LimitReader returns a reader that yields at most limit bytes from r and
|
||||||
|
// then fails with ErrOutputTooLarge. Unlike io.LimitReader, which reports
|
||||||
|
// a silent io.EOF at the limit (indistinguishable from a stream that
|
||||||
|
// simply ended), this fails, so a caller decoding or copying the stream
|
||||||
|
// sees an error rather than a truncated value. A stream of exactly limit
|
||||||
|
// bytes reads back cleanly to EOF; the first byte beyond it is the error.
|
||||||
|
func LimitReader(r io.Reader, limit int64) io.Reader {
|
||||||
|
// remaining counts down from limit+1: the extra byte is the one that,
|
||||||
|
// if it ever arrives, proves the stream is longer than the limit.
|
||||||
|
return &limitReader{r: r, remaining: limit + 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitReader struct {
|
||||||
|
r io.Reader
|
||||||
|
remaining int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitReader) Read(p []byte) (int, error) {
|
||||||
|
if l.remaining <= 0 {
|
||||||
|
return 0, ErrOutputTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(p)) > l.remaining {
|
||||||
|
p = p[:l.remaining]
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := l.r.Read(p)
|
||||||
|
l.remaining -= int64(n)
|
||||||
|
|
||||||
|
if l.remaining <= 0 {
|
||||||
|
// The (limit+1)th byte was just read: the stream is too long.
|
||||||
|
return n, ErrOutputTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLimitReaderPassesExactSize checks that a stream of exactly the limit
|
||||||
|
// reads back cleanly to EOF: the bound must not reject a legitimate blob
|
||||||
|
// whose plaintext equals its recorded size.
|
||||||
|
func TestLimitReaderPassesExactSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const n = 1000
|
||||||
|
|
||||||
|
r := blobgen.LimitReader(bytes.NewReader(bytes.Repeat([]byte("a"), n)), n)
|
||||||
|
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLimitReaderFailsPastLimit feeds a large, highly compressible run of
|
||||||
|
// zeros — the decompressed output a zip bomb would produce — through a
|
||||||
|
// small limit and checks it fails within the bound rather than passing
|
||||||
|
// the whole stream through.
|
||||||
|
func TestLimitReaderFailsPastLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 1000
|
||||||
|
|
||||||
|
r := blobgen.LimitReader(
|
||||||
|
bytes.NewReader(bytes.Repeat([]byte{0}, limit*1000)), limit)
|
||||||
|
|
||||||
|
n, err := io.Copy(io.Discard, r)
|
||||||
|
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
||||||
|
require.LessOrEqual(t, n, int64(limit)+1,
|
||||||
|
"reader must stop within one byte of the limit")
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewReaderWrongIdentity covers issue case 4: opening a blob with an
|
||||||
|
// identity other than the recipient reports no matching identity.
|
||||||
|
func TestNewReaderWrongIdentity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, recipient := makeIdentity(t)
|
||||||
|
other, _ := makeIdentity(t)
|
||||||
|
|
||||||
|
blob := encryptBlob(t, []byte("secret payload"), recipient)
|
||||||
|
|
||||||
|
_, err := blobgen.NewReader(bytes.NewReader(blob), other)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var noMatch *age.NoIdentityMatchError
|
||||||
|
assert.ErrorAs(t, err, &noMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewReaderTruncated covers issue case 6: a multi-segment blob cut at
|
||||||
|
// several points must never read back as valid data. The point immediately
|
||||||
|
// after the header and nonce is intentionally excluded: it reads as a valid
|
||||||
|
// empty blob today and is the regression case for
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/152.
|
||||||
|
func TestNewReaderTruncated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id, recipient := makeIdentity(t)
|
||||||
|
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
||||||
|
h := ageHeaderLen(t, blob)
|
||||||
|
|
||||||
|
require.Greater(t, len(blob), h+ageNonceSize+ageSegmentSize,
|
||||||
|
"test needs a blob of at least two age segments")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
}{
|
||||||
|
{"inside header", h / 2},
|
||||||
|
{"inside nonce", h + 8},
|
||||||
|
{"inside first segment", h + ageNonceSize + 100},
|
||||||
|
{"end of first full segment", h + ageNonceSize + ageSegmentSize},
|
||||||
|
{"last byte removed", len(blob) - 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireBlobUnreadable(t, blob[:tc.size], id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewReaderCorrupted covers issue case 7: one flipped byte in each region
|
||||||
|
// of a multi-segment blob makes it unreadable.
|
||||||
|
func TestNewReaderCorrupted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id, recipient := makeIdentity(t)
|
||||||
|
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
||||||
|
h := ageHeaderLen(t, blob)
|
||||||
|
|
||||||
|
firstNL := bytes.IndexByte(blob, '\n')
|
||||||
|
require.Positive(t, firstNL, "header must have a version line")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pos int
|
||||||
|
}{
|
||||||
|
{"header stanza", firstNL + 5},
|
||||||
|
{"header MAC line", h - 2},
|
||||||
|
{"nonce", h + 4},
|
||||||
|
{"body segment", h + ageNonceSize + 50},
|
||||||
|
{"final tag", len(blob) - 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
corrupt := append([]byte(nil), blob...)
|
||||||
|
corrupt[tc.pos] ^= 0xff
|
||||||
|
requireBlobUnreadable(t, corrupt, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewReaderTrailingAndGarbage covers issue case 8: bytes appended after a
|
||||||
|
// valid blob, empty input, and random garbage each fail to read.
|
||||||
|
func TestNewReaderTrailingAndGarbage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id, recipient := makeIdentity(t)
|
||||||
|
|
||||||
|
valid := encryptBlob(t, []byte("small payload"), recipient)
|
||||||
|
appended := append(append([]byte(nil), valid...), []byte("trailing junk")...)
|
||||||
|
|
||||||
|
t.Run("appended bytes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireBlobUnreadable(t, appended, id)
|
||||||
|
})
|
||||||
|
t.Run("empty input", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireBlobUnreadable(t, []byte{}, id)
|
||||||
|
})
|
||||||
|
t.Run("random garbage", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireBlobUnreadable(t, randomBytes(t, 512), id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
|
||||||
|
// out-of-range compression level errors and writes nothing to the destination.
|
||||||
|
func TestNewWriterInvalidLevel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, recipient := makeIdentity(t)
|
||||||
|
|
||||||
|
for _, level := range []int{0, -1, 20} {
|
||||||
|
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||||
|
require.ErrorIs(t, err, blobgen.ErrInvalidCompressionLevel)
|
||||||
|
assert.Nil(t, w)
|
||||||
|
assert.Zero(t, buf.Len(), "nothing written on an invalid level")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriterInvalidRecipients covers issue case 10: nil and empty recipient
|
||||||
|
// lists and an unparsable recipient string each error.
|
||||||
|
func TestNewWriterInvalidRecipients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
recipients []string
|
||||||
|
}{
|
||||||
|
{"nil list", nil},
|
||||||
|
{"empty list", []string{}},
|
||||||
|
{"invalid recipient string", []string{"not-a-recipient"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
w, err := blobgen.NewWriter(&buf, 1, tc.recipients)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriterFailingDestination covers issue case 11: a destination that
|
||||||
|
// fails mid-blob surfaces its error from Write or Close.
|
||||||
|
func TestNewWriterFailingDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, recipient := makeIdentity(t)
|
||||||
|
|
||||||
|
// The limit clears the age header and nonce so NewWriter succeeds, then
|
||||||
|
// trips once the compressed body starts flowing.
|
||||||
|
dst := &failAfterWriter{limit: 512}
|
||||||
|
|
||||||
|
w, err := blobgen.NewWriter(dst, 1, []string{recipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, writeErr := w.Write(randomBytes(t, 256*1024))
|
||||||
|
closeErr := w.Close()
|
||||||
|
|
||||||
|
assert.True(t, writeErr != nil || closeErr != nil,
|
||||||
|
"destination failure must surface from Write or Close")
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package blobgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reader wraps decompression and decryption with SHA256 verification
|
||||||
|
type Reader struct {
|
||||||
|
reader io.Reader
|
||||||
|
decompressor *zstd.Decoder
|
||||||
|
decryptor io.Reader
|
||||||
|
hasher hash.Hash
|
||||||
|
teeReader io.Reader
|
||||||
|
bytesRead int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReader creates a new Reader that decrypts, decompresses, and verifies
|
||||||
|
// data. Every supplied identity is offered to age.Decrypt, so a blob
|
||||||
|
// encrypted to any one of them can be read.
|
||||||
|
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
||||||
|
// Create decryption reader
|
||||||
|
decReader, err := age.Decrypt(r, identities...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create decompression reader
|
||||||
|
decompressor, err := zstd.NewReader(decReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating decompression reader: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SHA256 hasher
|
||||||
|
hasher := sha256.New()
|
||||||
|
|
||||||
|
// Create tee reader that reads from decompressor and writes to hasher
|
||||||
|
teeReader := io.TeeReader(decompressor, hasher)
|
||||||
|
|
||||||
|
return &Reader{
|
||||||
|
reader: r,
|
||||||
|
decompressor: decompressor,
|
||||||
|
decryptor: decReader,
|
||||||
|
hasher: hasher,
|
||||||
|
teeReader: teeReader,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read implements io.Reader
|
||||||
|
func (r *Reader) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.teeReader.Read(p)
|
||||||
|
r.bytesRead += int64(n)
|
||||||
|
|
||||||
|
// When the ciphertext is cut right after the age header plus its
|
||||||
|
// 16-byte nonce, the age reader's first read fails with
|
||||||
|
// io.ErrUnexpectedEOF, and the zstd decoder maps that to a clean
|
||||||
|
// io.EOF at frame start. That makes a truncated stream look like a
|
||||||
|
// valid empty one. Distinguish the two: on EOF, read once more from
|
||||||
|
// the age reader. A genuine end leaves it at (0, io.EOF); a truncated
|
||||||
|
// stream leaves its stored io.ErrUnexpectedEOF, which we surface.
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
var probe [1]byte
|
||||||
|
|
||||||
|
m, ageErr := r.decryptor.Read(probe[:])
|
||||||
|
if m != 0 || !errors.Is(ageErr, io.EOF) {
|
||||||
|
return n, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the decompressor
|
||||||
|
func (r *Reader) Close() error {
|
||||||
|
r.decompressor.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum256 returns the single SHA-256 of the plaintext read so far. This is the
|
||||||
|
// first hash only; the stored object name is its double hash, which callers
|
||||||
|
// obtain by passing this digest to DoubleSHA256.
|
||||||
|
func (r *Reader) Sum256() []byte {
|
||||||
|
return r.hasher.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesRead returns the number of uncompressed bytes read
|
||||||
|
func (r *Reader) BytesRead() int64 {
|
||||||
|
return r.bytesRead
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMultipleRecipients verifies that data written for several recipients can
|
||||||
|
// be read back by each recipient's identity. Moved from internal/crypto, which
|
||||||
|
// held the only multi-recipient test; blobgen is now the sole encryption path.
|
||||||
|
func TestMultipleRecipients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
identities := make([]*age.X25519Identity, 3)
|
||||||
|
recipients := make([]string, 3)
|
||||||
|
|
||||||
|
for i := range identities {
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
identities[i] = identity
|
||||||
|
recipients[i] = identity.Recipient().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := []byte("Secret message for multiple recipients")
|
||||||
|
|
||||||
|
var encrypted bytes.Buffer
|
||||||
|
|
||||||
|
writer, err := blobgen.NewWriter(&encrypted, 3, recipients)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = writer.Write(plaintext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
// Every recipient's identity must recover the original plaintext.
|
||||||
|
for i, identity := range identities {
|
||||||
|
reader, err := blobgen.NewReader(
|
||||||
|
bytes.NewReader(encrypted.Bytes()), identity)
|
||||||
|
require.NoError(t, err, "recipient %d should open the reader", i+1)
|
||||||
|
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err, "recipient %d should read the plaintext", i+1)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, plaintext, got,
|
||||||
|
"recipient %d should recover the original plaintext", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkRoundTrip writes input through a Writer, reads it back through a Reader,
|
||||||
|
// and verifies the plaintext, the byte counts, and the content hashes.
|
||||||
|
func checkRoundTrip(
|
||||||
|
t *testing.T, id *age.X25519Identity, recipient string,
|
||||||
|
level int, input []byte,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
n, err := w.Write(input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, len(input), n)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.Equal(t, int64(len(input)), w.BytesWritten())
|
||||||
|
|
||||||
|
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, input, got, "decrypted output must equal input")
|
||||||
|
require.Equal(t, int64(len(input)), r.BytesRead())
|
||||||
|
|
||||||
|
// The hash values are checked by decrypting: the reader's single SHA-256
|
||||||
|
// is the hash of the plaintext, and hashing it once more (DoubleSHA256)
|
||||||
|
// gives the writer's ContentID.
|
||||||
|
single := sha256.Sum256(got)
|
||||||
|
assert.Equal(t, single[:], r.Sum256())
|
||||||
|
assert.Equal(t, blobgen.DoubleSHA256(r.Sum256()), w.ContentID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriterReaderRoundTrip covers issue cases 1 and 2: every size round trips
|
||||||
|
// for both random and compressible data, and the reader hash, its double hash
|
||||||
|
// and the byte counts all agree.
|
||||||
|
func TestWriterReaderRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id, recipient := makeIdentity(t)
|
||||||
|
|
||||||
|
// Sizes exercise the age segment boundary (64 KiB) from just below to a
|
||||||
|
// few segments above it, plus the empty and single-byte edges.
|
||||||
|
sizes := []int{0, 1, 65535, 65536, 65537, 4*65536 + 123}
|
||||||
|
|
||||||
|
kinds := []struct {
|
||||||
|
name string
|
||||||
|
fill func(*testing.T, int) []byte
|
||||||
|
}{
|
||||||
|
{"random", randomBytes},
|
||||||
|
{"compressible", func(_ *testing.T, n int) []byte {
|
||||||
|
return compressibleBytes(n)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range kinds {
|
||||||
|
for _, size := range sizes {
|
||||||
|
name := fmt.Sprintf("%s/%d", k.name, size)
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
checkRoundTrip(t, id, recipient, 1, k.fill(t, size))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZeroLengthNoWrite covers issue case 3: a Writer closed with no Write at
|
||||||
|
// all produces the double hash of the empty input, and the blob reads back as
|
||||||
|
// empty with no error.
|
||||||
|
func TestZeroLengthNoWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id, recipient := makeIdentity(t)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
w, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
assert.Equal(t, int64(0), w.BytesWritten())
|
||||||
|
|
||||||
|
empty := sha256.Sum256(nil)
|
||||||
|
doubled := sha256.Sum256(empty[:])
|
||||||
|
assert.Equal(t, doubled[:], w.ContentID(),
|
||||||
|
"ContentID of empty input is SHA256(SHA256(\"\"))")
|
||||||
|
|
||||||
|
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
|
||||||
|
assert.Empty(t, got, "empty blob decrypts to empty output")
|
||||||
|
assert.Equal(t, int64(0), r.BytesRead())
|
||||||
|
assert.Equal(t, empty[:], r.Sum256())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriterValidLevelsRoundTrip covers the accepted end of issue case 9:
|
||||||
|
// the boundary compression levels 1 and 19 both round trip.
|
||||||
|
func TestNewWriterValidLevelsRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id, recipient := makeIdentity(t)
|
||||||
|
input := randomBytes(t, 4096)
|
||||||
|
|
||||||
|
for _, level := range []int{1, 19} {
|
||||||
|
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
checkRoundTrip(t, id, recipient, level, input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReaderRejectsHeaderNonceTruncation guards against a stream cut right
|
||||||
|
// after the age header plus its 16-byte nonce. age.Decrypt still succeeds on
|
||||||
|
// such an object, and the zstd decoder maps the age reader's
|
||||||
|
// io.ErrUnexpectedEOF to a clean io.EOF at frame start, so without the extra
|
||||||
|
// check the truncated stream would read as a valid empty one. Reading it must
|
||||||
|
// now fail.
|
||||||
|
func TestReaderRejectsHeaderNonceTruncation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Encrypting empty plaintext yields header + nonce(16) + a single
|
||||||
|
// 16-byte final chunk tag. Dropping the trailing tag leaves exactly the
|
||||||
|
// age header plus its nonce — the truncation point that triggers the bug.
|
||||||
|
var full bytes.Buffer
|
||||||
|
|
||||||
|
w, err := age.Encrypt(&full, identity.Recipient())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
truncated := full.Bytes()[:full.Len()-16]
|
||||||
|
|
||||||
|
reader, err := blobgen.NewReader(bytes.NewReader(truncated), identity)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
|
_, err = io.ReadAll(reader)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReaderReadsGenuinelyEmptyBlob confirms the truncation check does not
|
||||||
|
// reject a legitimately empty payload: a blob written with no data must round
|
||||||
|
// trip back to zero bytes with no error.
|
||||||
|
func TestReaderReadsGenuinelyEmptyBlob(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var encrypted bytes.Buffer
|
||||||
|
|
||||||
|
writer, err := blobgen.NewWriter(
|
||||||
|
&encrypted, 3, []string{identity.Recipient().String()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
reader, err := blobgen.NewReader(bytes.NewReader(encrypted.Bytes()), identity)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Package blobgen implements the blob data pipeline: streaming zstd
|
||||||
|
// compression, age encryption, and SHA256 content hashing for blob
|
||||||
|
// creation, plus the matching decrypt/decompress/verify reader.
|
||||||
|
package blobgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoubleSHA256 returns the double SHA-256 of content whose single SHA-256
|
||||||
|
// digest is sum: it hashes that digest once more. Stored objects — a blob, and
|
||||||
|
// the metadata database export — are named by this second hash.
|
||||||
|
//
|
||||||
|
// The second hash does not hide whether known content is stored: an attacker
|
||||||
|
// who can reproduce an object's entire plaintext computes the same name simply
|
||||||
|
// by hashing twice, exactly as this code does. What limits that is blob
|
||||||
|
// packing, not the double hash — a blob's name covers all of its concatenated
|
||||||
|
// chunk plaintext, so a name can be confirmed only by someone who can
|
||||||
|
// reproduce the whole blob (a snapshot made entirely of known content, or a
|
||||||
|
// known file large enough to fill blobs on its own). An ordinary file that
|
||||||
|
// shares a blob with other, unknown data cannot be confirmed this way.
|
||||||
|
func DoubleSHA256(sum []byte) []byte {
|
||||||
|
h := sha256.Sum256(sum)
|
||||||
|
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zstd compression level bounds accepted by NewWriter.
|
||||||
|
const (
|
||||||
|
minCompressionLevel = 1
|
||||||
|
maxCompressionLevel = 19
|
||||||
|
)
|
||||||
|
|
||||||
|
// reservedCompressionCPUs is how many CPUs are left free of zstd
|
||||||
|
// compression work for I/O and hashing.
|
||||||
|
const reservedCompressionCPUs = 2
|
||||||
|
|
||||||
|
// ErrInvalidCompressionLevel is returned when the zstd compression level
|
||||||
|
// is outside the accepted 1-19 range.
|
||||||
|
var ErrInvalidCompressionLevel = errors.New(
|
||||||
|
"invalid compression level: must be between 1 and 19")
|
||||||
|
|
||||||
|
// errInvalidRecipient is returned when a recipient string does not parse as
|
||||||
|
// an X25519 age1... public key. It omits the value, which can be sensitive.
|
||||||
|
var errInvalidRecipient = errors.New(
|
||||||
|
"not a valid X25519 age1... recipient")
|
||||||
|
|
||||||
|
// Writer wraps compression and encryption with SHA256 hashing.
|
||||||
|
// Data flows: input -> tee(hasher, compressor -> encryptor -> destination)
|
||||||
|
// The hash is computed on the uncompressed input for deterministic content-addressing.
|
||||||
|
type Writer struct {
|
||||||
|
teeWriter io.Writer // Tee to hasher and compressor
|
||||||
|
compressor *zstd.Encoder // Compression layer
|
||||||
|
encryptor io.WriteCloser // Encryption layer
|
||||||
|
hasher hash.Hash // SHA256 hasher (on uncompressed input)
|
||||||
|
compressionLevel int
|
||||||
|
bytesWritten int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriter creates a new Writer that compresses, encrypts, and hashes
|
||||||
|
// data. The hash is computed on the uncompressed input for deterministic
|
||||||
|
// content-addressing.
|
||||||
|
func NewWriter(
|
||||||
|
w io.Writer, compressionLevel int, recipients []string,
|
||||||
|
) (*Writer, error) {
|
||||||
|
// Validate compression level
|
||||||
|
err := validateCompressionLevel(compressionLevel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SHA256 hasher for the uncompressed input
|
||||||
|
hasher := sha256.New()
|
||||||
|
|
||||||
|
// Parse recipients
|
||||||
|
var ageRecipients []age.Recipient
|
||||||
|
|
||||||
|
for i, recipient := range recipients {
|
||||||
|
// The recipient string can be sensitive (e.g. a secret key pasted by
|
||||||
|
// mistake), so the error names its position, never its value.
|
||||||
|
r, err := age.ParseX25519Recipient(recipient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: recipient %d", errInvalidRecipient, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
ageRecipients = append(ageRecipients, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create encryption writer that outputs to destination
|
||||||
|
encWriter, err := age.Encrypt(w, ageRecipients...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating encryption writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate compression concurrency: CPUs - 2, minimum 1
|
||||||
|
concurrency := max(runtime.NumCPU()-reservedCompressionCPUs, 1)
|
||||||
|
|
||||||
|
// Create compression writer with encryption as destination
|
||||||
|
compressor, err := zstd.NewWriter(encWriter,
|
||||||
|
zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(compressionLevel)),
|
||||||
|
zstd.WithEncoderConcurrency(concurrency),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = encWriter.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("creating compression writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tee writer: input goes to both hasher and compressor
|
||||||
|
teeWriter := io.MultiWriter(hasher, compressor)
|
||||||
|
|
||||||
|
return &Writer{
|
||||||
|
teeWriter: teeWriter,
|
||||||
|
compressor: compressor,
|
||||||
|
encryptor: encWriter,
|
||||||
|
hasher: hasher,
|
||||||
|
compressionLevel: compressionLevel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements io.Writer
|
||||||
|
func (w *Writer) Write(p []byte) (int, error) {
|
||||||
|
n, err := w.teeWriter.Write(p)
|
||||||
|
w.bytesWritten += int64(n)
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all layers and returns any errors
|
||||||
|
func (w *Writer) Close() error {
|
||||||
|
// Close compressor first
|
||||||
|
err := w.compressor.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("closing compressor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then close encryptor
|
||||||
|
err = w.encryptor.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("closing encryptor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentID returns the double SHA-256 of the uncompressed input data: the
|
||||||
|
// name under which this content is stored. It is the second hash of the
|
||||||
|
// running SHA-256, via DoubleSHA256; see that function for what naming content
|
||||||
|
// this way does and does not hide.
|
||||||
|
func (w *Writer) ContentID() []byte {
|
||||||
|
return DoubleSHA256(w.hasher.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesWritten returns the number of uncompressed bytes written
|
||||||
|
func (w *Writer) BytesWritten() int64 {
|
||||||
|
return w.bytesWritten
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCompressionLevel(level int) error {
|
||||||
|
// Zstd compression levels: 1-19 (default is 3)
|
||||||
|
// SpeedFastest = 1, SpeedDefault = 3, SpeedBetterCompression = 7,
|
||||||
|
// SpeedBestCompression = 11
|
||||||
|
if level < minCompressionLevel || level > maxCompressionLevel {
|
||||||
|
return fmt.Errorf("%w: got %d", ErrInvalidCompressionLevel, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriterHashIsDoubleHash verifies that Writer.ContentID() returns
|
||||||
|
// SHA256(SHA256(plaintext)). Stored objects are named by this second hash so a
|
||||||
|
// name is not the plaintext's own SHA-256; this does not stop someone who
|
||||||
|
// already holds the plaintext from confirming it.
|
||||||
|
func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test data - random data that doesn't compress well
|
||||||
|
testData := make([]byte, 1024*1024) // 1MB
|
||||||
|
_, err := rand.Read(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test recipient (generated with age-keygen)
|
||||||
|
testRecipient := "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s89gmvv"
|
||||||
|
|
||||||
|
// Create a buffer to capture the encrypted output
|
||||||
|
var encryptedBuf bytes.Buffer
|
||||||
|
|
||||||
|
// Create blobgen writer
|
||||||
|
writer, err := blobgen.NewWriter(&encryptedBuf, 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write test data
|
||||||
|
n, err := writer.Write(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, len(testData), n)
|
||||||
|
|
||||||
|
// Close to flush all data
|
||||||
|
err = writer.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get the hash from the writer
|
||||||
|
writerHash := hex.EncodeToString(writer.ContentID())
|
||||||
|
|
||||||
|
// Calculate the expected double hash: SHA256(SHA256(plaintext))
|
||||||
|
firstHash := sha256.Sum256(testData)
|
||||||
|
secondHash := sha256.Sum256(firstHash[:])
|
||||||
|
expectedDoubleHash := hex.EncodeToString(secondHash[:])
|
||||||
|
|
||||||
|
// Also compute single hash to verify it's different
|
||||||
|
singleHashStr := hex.EncodeToString(firstHash[:])
|
||||||
|
|
||||||
|
t.Logf("Input size: %d bytes", len(testData))
|
||||||
|
t.Logf("Single hash (SHA256(data)): %s", singleHashStr)
|
||||||
|
t.Logf("Double hash (SHA256(SHA256(data))): %s", expectedDoubleHash)
|
||||||
|
t.Logf("Writer hash: %s", writerHash)
|
||||||
|
|
||||||
|
// The writer hash should match the double hash
|
||||||
|
assert.Equal(t, expectedDoubleHash, writerHash,
|
||||||
|
"Writer.ContentID() must be SHA256(SHA256(plaintext))")
|
||||||
|
|
||||||
|
// It must be the second hash, not the plaintext's own SHA-256.
|
||||||
|
assert.NotEqual(t, singleHashStr, writerHash,
|
||||||
|
"Writer hash must be the double hash, not the single SHA-256")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriterDeterministicHash verifies that the same input always produces
|
||||||
|
// the same hash, even with non-deterministic encryption.
|
||||||
|
func TestWriterDeterministicHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
testData := []byte("Hello, World! This is test data for deterministic hashing.")
|
||||||
|
|
||||||
|
// Test recipient
|
||||||
|
testRecipient := "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s89gmvv"
|
||||||
|
|
||||||
|
// Create two writers and verify they produce the same hash
|
||||||
|
var buf1, buf2 bytes.Buffer
|
||||||
|
|
||||||
|
writer1, err := blobgen.NewWriter(&buf1, 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = writer1.Write(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer1.Close())
|
||||||
|
|
||||||
|
writer2, err := blobgen.NewWriter(&buf2, 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = writer2.Write(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer2.Close())
|
||||||
|
|
||||||
|
hash1 := hex.EncodeToString(writer1.ContentID())
|
||||||
|
hash2 := hex.EncodeToString(writer2.ContentID())
|
||||||
|
|
||||||
|
// Hashes should be identical (deterministic)
|
||||||
|
assert.Equal(t, hash1, hash2, "Same input should produce same hash")
|
||||||
|
|
||||||
|
// Encrypted outputs should be different (non-deterministic encryption)
|
||||||
|
assert.NotEqual(t, buf1.Bytes(), buf2.Bytes(),
|
||||||
|
"Encrypted outputs should differ due to non-deterministic encryption")
|
||||||
|
|
||||||
|
t.Logf("Hash 1: %s", hash1)
|
||||||
|
t.Logf("Hash 2: %s", hash2)
|
||||||
|
t.Logf("Encrypted size 1: %d bytes", buf1.Len())
|
||||||
|
t.Logf("Encrypted size 2: %d bytes", buf2.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriterSecretKeyNotEchoed verifies that a secret key mistakenly passed
|
||||||
|
// as a recipient does not appear in the returned error. A recipient string can
|
||||||
|
// be sensitive, so the error must name only the position, not the value.
|
||||||
|
func TestNewWriterSecretKeyNotEchoed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
secretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
||||||
|
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
_, err := blobgen.NewWriter(&buf, 3, []string{secretKey})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), secretKey,
|
||||||
|
"error must not echo the recipient value")
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Package chunker splits input data into content-defined chunks using the
|
||||||
|
// FastCDC algorithm so that identical data sequences produce identical
|
||||||
|
// chunks regardless of their position in the file.
|
||||||
|
package chunker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunk represents a single chunk of data produced by the content-defined
|
||||||
|
// chunking algorithm. Each chunk is identified by its SHA256 hash and
|
||||||
|
// contains the raw data along with its position and size information from
|
||||||
|
// the original file.
|
||||||
|
type Chunk struct {
|
||||||
|
Hash string // Content hash of the chunk
|
||||||
|
Data []byte // Chunk data
|
||||||
|
Offset int64 // Offset in the original file
|
||||||
|
Size int64 // Size of the chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunker provides content-defined chunking using the FastCDC algorithm.
|
||||||
|
// It splits data into variable-sized chunks based on content patterns, ensuring
|
||||||
|
// that identical data sequences produce identical chunks regardless of their
|
||||||
|
// position in the file. This enables efficient deduplication.
|
||||||
|
type Chunker struct {
|
||||||
|
avgChunkSize int
|
||||||
|
minChunkSize int
|
||||||
|
maxChunkSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkSizeSpread is the FastCDC-recommended factor between the average
|
||||||
|
// chunk size and the minimum (avg/spread) and maximum (avg*spread) sizes.
|
||||||
|
// The largest chunk the chunker can emit is therefore avg*ChunkSizeSpread.
|
||||||
|
const ChunkSizeSpread = 4
|
||||||
|
|
||||||
|
// NewChunker creates a new chunker with the specified average chunk size.
|
||||||
|
// The actual chunk sizes will vary between avgChunkSize/4 and avgChunkSize*4
|
||||||
|
// as recommended by the FastCDC algorithm. Typical values for avgChunkSize
|
||||||
|
// are 64KB (65536), 256KB (262144), or 1MB (1048576).
|
||||||
|
func NewChunker(avgChunkSize int64) *Chunker {
|
||||||
|
// FastCDC recommends min = avg/4 and max = avg*4
|
||||||
|
return &Chunker{
|
||||||
|
avgChunkSize: int(avgChunkSize),
|
||||||
|
minChunkSize: int(avgChunkSize / ChunkSizeSpread),
|
||||||
|
maxChunkSize: int(avgChunkSize * ChunkSizeSpread),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkReader splits the reader into content-defined chunks and returns all
|
||||||
|
// chunks at once. This method loads all chunk data into memory, so it should
|
||||||
|
// only be used for reasonably sized inputs. For large files or streams, use
|
||||||
|
// ChunkReaderStreaming instead.
|
||||||
|
// Returns an error if chunking fails or if reading from the input fails.
|
||||||
|
func (c *Chunker) ChunkReader(r io.Reader) ([]Chunk, error) {
|
||||||
|
chunker := AcquireReusableChunker(
|
||||||
|
r, c.minChunkSize, c.avgChunkSize, c.maxChunkSize)
|
||||||
|
defer chunker.Release()
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
|
||||||
|
offset := int64(0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
chunk, err := chunker.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading chunk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hash
|
||||||
|
hash := sha256.Sum256(chunk.Data)
|
||||||
|
|
||||||
|
// Make a copy of the data since the chunker reuses the buffer
|
||||||
|
chunkData := make([]byte, len(chunk.Data))
|
||||||
|
copy(chunkData, chunk.Data)
|
||||||
|
|
||||||
|
chunks = append(chunks, Chunk{
|
||||||
|
Hash: hex.EncodeToString(hash[:]),
|
||||||
|
Data: chunkData,
|
||||||
|
Offset: offset,
|
||||||
|
Size: int64(len(chunk.Data)),
|
||||||
|
})
|
||||||
|
|
||||||
|
offset += int64(len(chunk.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkCallback is a function called for each chunk as it's processed.
|
||||||
|
// The callback receives a Chunk containing the hash, data, offset, and size.
|
||||||
|
// If the callback returns an error, chunk processing stops and the error is
|
||||||
|
// propagated.
|
||||||
|
type ChunkCallback func(chunk Chunk) error
|
||||||
|
|
||||||
|
// ChunkReaderStreaming splits the reader into chunks and calls the callback
|
||||||
|
// for each chunk. This is the preferred method for processing large files or
|
||||||
|
// streams as it doesn't accumulate all chunks in memory. The callback is
|
||||||
|
// invoked for each chunk as it's produced, allowing for streaming processing
|
||||||
|
// and immediate storage or transmission.
|
||||||
|
// Returns the SHA256 hash of the entire file content and an error if
|
||||||
|
// chunking fails, reading fails, or if the callback returns an error.
|
||||||
|
func (c *Chunker) ChunkReaderStreaming(
|
||||||
|
r io.Reader, callback ChunkCallback,
|
||||||
|
) (string, error) {
|
||||||
|
// Create a tee reader to calculate full file hash while chunking
|
||||||
|
fileHasher := sha256.New()
|
||||||
|
teeReader := io.TeeReader(r, fileHasher)
|
||||||
|
|
||||||
|
chunker := AcquireReusableChunker(
|
||||||
|
teeReader, c.minChunkSize, c.avgChunkSize, c.maxChunkSize)
|
||||||
|
defer chunker.Release()
|
||||||
|
|
||||||
|
offset := int64(0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
chunk, err := chunker.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading chunk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate chunk hash
|
||||||
|
hash := sha256.Sum256(chunk.Data)
|
||||||
|
|
||||||
|
// Pass the data directly - caller must process it before we call
|
||||||
|
// Next() again (chunker reuses its internal buffer, but since we
|
||||||
|
// process synchronously and completely before continuing, no copy
|
||||||
|
// is needed)
|
||||||
|
err = callback(Chunk{
|
||||||
|
Hash: hex.EncodeToString(hash[:]),
|
||||||
|
Data: chunk.Data,
|
||||||
|
Offset: offset,
|
||||||
|
Size: int64(len(chunk.Data)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += int64(len(chunk.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the full file hash
|
||||||
|
return hex.EncodeToString(fileHasher.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkFile splits a file into content-defined chunks by reading the entire file.
|
||||||
|
// This is a convenience method that opens the file and passes it to ChunkReader.
|
||||||
|
// For large files, consider using ChunkReaderStreaming with a file handle instead.
|
||||||
|
// Returns an error if the file cannot be opened or if chunking fails.
|
||||||
|
func (c *Chunker) ChunkFile(path string) ([]Chunk, error) {
|
||||||
|
file, err := os.Open(path) //nolint:gosec // G304: path is caller-supplied by design
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := file.Close()
|
||||||
|
if err != nil && err.Error() != "invalid argument" {
|
||||||
|
// Log error or handle as needed
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return c.ChunkReader(file)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package chunker_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChunkerExpectedChunkCount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fileSize int
|
||||||
|
avgChunkSize int64
|
||||||
|
minExpected int
|
||||||
|
maxExpected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "1MB file with 64KB average",
|
||||||
|
fileSize: 1024 * 1024,
|
||||||
|
avgChunkSize: 64 * 1024,
|
||||||
|
minExpected: 8, // At least half the expected count
|
||||||
|
maxExpected: 32, // At most double the expected count
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10MB file with 256KB average",
|
||||||
|
fileSize: 10 * 1024 * 1024,
|
||||||
|
avgChunkSize: 256 * 1024,
|
||||||
|
minExpected: 10, // FastCDC may produce larger chunks
|
||||||
|
maxExpected: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "512KB file with 64KB average",
|
||||||
|
fileSize: 512 * 1024,
|
||||||
|
avgChunkSize: 64 * 1024,
|
||||||
|
minExpected: 4, // ~8 expected
|
||||||
|
maxExpected: 16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := chunker.NewChunker(tt.avgChunkSize)
|
||||||
|
|
||||||
|
// Create data with some variation to trigger chunk boundaries
|
||||||
|
data := make([]byte, tt.fileSize)
|
||||||
|
for i := range data {
|
||||||
|
// Use a pattern that should create boundaries
|
||||||
|
//nolint:gosec // G115: intentional byte truncation
|
||||||
|
data[i] = byte((i * 17) ^ (i >> 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks, err := c.ChunkReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chunking failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Created %d chunks for %d bytes with %d average chunk size",
|
||||||
|
len(chunks), tt.fileSize, tt.avgChunkSize)
|
||||||
|
|
||||||
|
if len(chunks) < tt.minExpected {
|
||||||
|
t.Errorf("too few chunks: got %d, expected at least %d",
|
||||||
|
len(chunks), tt.minExpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) > tt.maxExpected {
|
||||||
|
t.Errorf("too many chunks: got %d, expected at most %d",
|
||||||
|
len(chunks), tt.maxExpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chunks reconstruct to original
|
||||||
|
var reconstructed []byte
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
reconstructed = append(reconstructed, chunk.Data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(data, reconstructed) {
|
||||||
|
t.Error("reconstructed data doesn't match original")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package chunker_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChunkerSmallFileSingleChunk(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := chunker.NewChunker(1024 * 1024) // 1MB average
|
||||||
|
data := bytes.Repeat([]byte("hello"), 100) // 500 bytes
|
||||||
|
|
||||||
|
chunks, err := c.ChunkReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chunking failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) != 1 {
|
||||||
|
t.Errorf("expected 1 chunk, got %d", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunks[0].Size != int64(len(data)) {
|
||||||
|
t.Errorf("expected chunk size %d, got %d", len(data), chunks[0].Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkerLargeFileMultipleChunks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := chunker.NewChunker(256 * 1024) // 256KB average chunk size
|
||||||
|
|
||||||
|
// Generate 2MB of random data
|
||||||
|
data := make([]byte, 2*1024*1024)
|
||||||
|
|
||||||
|
_, err := rand.Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate random data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks, err := c.ChunkReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chunking failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should produce multiple chunks - with FastCDC we expect around 8
|
||||||
|
// chunks for 2MB with 256KB average
|
||||||
|
if len(chunks) < 4 || len(chunks) > 16 {
|
||||||
|
t.Errorf("expected 4-16 chunks, got %d", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chunks reconstruct original data
|
||||||
|
reconstructed := make([]byte, 0, len(data))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
reconstructed = append(reconstructed, chunk.Data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(data, reconstructed) {
|
||||||
|
t.Error("reconstructed data doesn't match original")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify offsets
|
||||||
|
var expectedOffset int64
|
||||||
|
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
if chunk.Offset != expectedOffset {
|
||||||
|
t.Errorf("chunk %d: expected offset %d, got %d",
|
||||||
|
i, expectedOffset, chunk.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedOffset += chunk.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkerDeterministic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
chunker1 := chunker.NewChunker(256 * 1024)
|
||||||
|
chunker2 := chunker.NewChunker(256 * 1024)
|
||||||
|
|
||||||
|
// Use deterministic data
|
||||||
|
data := bytes.Repeat([]byte("abcdefghijklmnopqrstuvwxyz"), 20000) // ~520KB
|
||||||
|
|
||||||
|
chunks1, err := chunker1.ChunkReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chunking failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks2, err := chunker2.ChunkReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chunking failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should produce same chunks
|
||||||
|
if len(chunks1) != len(chunks2) {
|
||||||
|
t.Fatalf("different number of chunks: %d vs %d",
|
||||||
|
len(chunks1), len(chunks2))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range chunks1 {
|
||||||
|
if chunks1[i].Hash != chunks2[i].Hash {
|
||||||
|
t.Errorf("chunk %d: different hashes", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunks1[i].Size != chunks2[i].Size {
|
||||||
|
t.Errorf("chunk %d: different sizes", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkBoundaries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := chunker.NewChunker(256 * 1024) // 256KB average
|
||||||
|
|
||||||
|
// FastCDC uses avg/4 for min and avg*4 for max
|
||||||
|
avgSize := int64(256 * 1024)
|
||||||
|
minSize := avgSize / 4
|
||||||
|
maxSize := avgSize * 4
|
||||||
|
|
||||||
|
// Test that minimum chunk size is respected
|
||||||
|
data := make([]byte, minSize+1024)
|
||||||
|
|
||||||
|
_, err := rand.Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate random data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks, err := c.ChunkReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chunking failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
// Last chunk can be smaller than minimum
|
||||||
|
if i < len(chunks)-1 && chunk.Size < minSize {
|
||||||
|
t.Errorf("chunk %d size %d is below minimum %d",
|
||||||
|
i, chunk.Size, minSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk.Size > maxSize {
|
||||||
|
t.Errorf("chunk %d size %d exceeds maximum %d",
|
||||||
|
i, chunk.Size, maxSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package chunker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReusableChunker implements FastCDC with reusable buffers to minimize allocations.
|
||||||
|
// Unlike the upstream fastcdc-go library which allocates a new buffer per file,
|
||||||
|
// this implementation uses sync.Pool to reuse buffers across files.
|
||||||
|
type ReusableChunker struct {
|
||||||
|
minSize int
|
||||||
|
maxSize int
|
||||||
|
normSize int
|
||||||
|
bufSize int
|
||||||
|
|
||||||
|
maskS uint64
|
||||||
|
maskL uint64
|
||||||
|
|
||||||
|
rd io.Reader
|
||||||
|
|
||||||
|
buf []byte
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
eof bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// reusableChunkerPool pools ReusableChunker instances to avoid allocations.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // process-wide object pool by design
|
||||||
|
var reusableChunkerPool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return &ReusableChunker{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferPools contains pools for different buffer sizes.
|
||||||
|
// Key is the buffer size.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // process-wide buffer pools by design
|
||||||
|
var bufferPools = sync.Map{}
|
||||||
|
|
||||||
|
func getBuffer(size int) []byte {
|
||||||
|
poolI, _ := bufferPools.LoadOrStore(size, &sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
buf := make([]byte, size)
|
||||||
|
|
||||||
|
return &buf
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
pool, ok := poolI.(*sync.Pool)
|
||||||
|
if !ok {
|
||||||
|
panic("bufferPools holds a non-pool value")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, ok := pool.Get().(*[]byte)
|
||||||
|
if !ok {
|
||||||
|
panic("buffer pool holds a non-buffer value")
|
||||||
|
}
|
||||||
|
|
||||||
|
return *buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func putBuffer(buf []byte) {
|
||||||
|
size := cap(buf)
|
||||||
|
|
||||||
|
poolI, ok := bufferPools.Load(size)
|
||||||
|
if ok {
|
||||||
|
pool, isPool := poolI.(*sync.Pool)
|
||||||
|
if !isPool {
|
||||||
|
panic("bufferPools holds a non-pool value")
|
||||||
|
}
|
||||||
|
|
||||||
|
b := buf[:size]
|
||||||
|
pool.Put(&b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FastCDCChunk represents a chunk from the FastCDC algorithm.
|
||||||
|
type FastCDCChunk struct {
|
||||||
|
Offset int
|
||||||
|
Length int
|
||||||
|
Data []byte
|
||||||
|
Fingerprint uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufSizeFactor sizes the internal read buffer relative to the maximum
|
||||||
|
// chunk size so a full chunk plus read-ahead always fits.
|
||||||
|
const bufSizeFactor = 2
|
||||||
|
|
||||||
|
// AcquireReusableChunker gets a chunker from the pool and initializes it
|
||||||
|
// for the given reader.
|
||||||
|
func AcquireReusableChunker(
|
||||||
|
rd io.Reader, minSize, avgSize, maxSize int,
|
||||||
|
) *ReusableChunker {
|
||||||
|
c, ok := reusableChunkerPool.Get().(*ReusableChunker)
|
||||||
|
if !ok {
|
||||||
|
panic("reusableChunkerPool holds a non-chunker value")
|
||||||
|
}
|
||||||
|
|
||||||
|
bufSize := maxSize * bufSizeFactor
|
||||||
|
|
||||||
|
// Reuse buffer if it's the right size, otherwise get a new one
|
||||||
|
if c.buf == nil || cap(c.buf) != bufSize {
|
||||||
|
if c.buf != nil {
|
||||||
|
putBuffer(c.buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.buf = getBuffer(bufSize)
|
||||||
|
} else {
|
||||||
|
// Restore buffer to full capacity (may have been truncated by previous EOF)
|
||||||
|
c.buf = c.buf[:cap(c.buf)]
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := int(math.Round(math.Log2(float64(avgSize))))
|
||||||
|
normalization := 2
|
||||||
|
smallBits := bits + normalization
|
||||||
|
largeBits := bits - normalization
|
||||||
|
|
||||||
|
c.minSize = minSize
|
||||||
|
c.maxSize = maxSize
|
||||||
|
c.normSize = avgSize
|
||||||
|
c.bufSize = bufSize
|
||||||
|
c.maskS = (1 << smallBits) - 1
|
||||||
|
c.maskL = (1 << largeBits) - 1
|
||||||
|
c.rd = rd
|
||||||
|
c.cursor = bufSize
|
||||||
|
c.offset = 0
|
||||||
|
c.eof = false
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release returns the chunker to the pool for reuse.
|
||||||
|
func (c *ReusableChunker) Release() {
|
||||||
|
c.rd = nil
|
||||||
|
reusableChunkerPool.Put(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next chunk or io.EOF when done.
|
||||||
|
// The returned Data slice is only valid until the next call to Next.
|
||||||
|
func (c *ReusableChunker) Next() (FastCDCChunk, error) {
|
||||||
|
err := c.fillBuffer()
|
||||||
|
if err != nil {
|
||||||
|
return FastCDCChunk{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.buf) == 0 {
|
||||||
|
return FastCDCChunk{}, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
length, fp := c.nextChunk(c.buf[c.cursor:])
|
||||||
|
|
||||||
|
chunk := FastCDCChunk{
|
||||||
|
Offset: c.offset,
|
||||||
|
Length: length,
|
||||||
|
Data: c.buf[c.cursor : c.cursor+length],
|
||||||
|
Fingerprint: fp,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cursor += length
|
||||||
|
c.offset += chunk.Length
|
||||||
|
|
||||||
|
return chunk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ReusableChunker) fillBuffer() error {
|
||||||
|
n := len(c.buf) - c.cursor
|
||||||
|
if n >= c.maxSize {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move all data after the cursor to the start of the buffer
|
||||||
|
copy(c.buf[:n], c.buf[c.cursor:])
|
||||||
|
c.cursor = 0
|
||||||
|
|
||||||
|
if c.eof {
|
||||||
|
c.buf = c.buf[:n]
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore buffer to full capacity for reading
|
||||||
|
c.buf = c.buf[:c.bufSize]
|
||||||
|
|
||||||
|
// Fill the rest of the buffer
|
||||||
|
m, err := io.ReadFull(c.rd, c.buf[n:])
|
||||||
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
c.buf = c.buf[:n+m]
|
||||||
|
c.eof = true
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ReusableChunker) nextChunk(data []byte) (int, uint64) {
|
||||||
|
fp := uint64(0)
|
||||||
|
i := c.minSize
|
||||||
|
|
||||||
|
if len(data) <= c.minSize {
|
||||||
|
return len(data), fp
|
||||||
|
}
|
||||||
|
|
||||||
|
n := min(len(data), c.maxSize)
|
||||||
|
|
||||||
|
for ; i < min(n, c.normSize); i++ {
|
||||||
|
fp = (fp << 1) + table[data[i]]
|
||||||
|
if (fp & c.maskS) == 0 {
|
||||||
|
return i + 1, fp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ; i < n; i++ {
|
||||||
|
fp = (fp << 1) + table[data[i]]
|
||||||
|
if (fp & c.maskL) == 0 {
|
||||||
|
return i + 1, fp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i, fp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 256 random uint64s for the rolling hash function (from FastCDC paper)
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // immutable FastCDC gear lookup table
|
||||||
|
var table = [256]uint64{
|
||||||
|
0xe80e8d55032474b3, 0x11b25b61f5924e15, 0x03aa5bd82a9eb669, 0xc45a153ef107a38c,
|
||||||
|
0xeac874b86f0f57b9, 0xa5ccedec95ec79c7, 0xe15a3320ad42ac0a, 0x5ed3583fa63cec15,
|
||||||
|
0xcd497bf624a4451d, 0xf9ade5b059683605, 0x773940c03fb11ca1, 0xa36b16e4a6ae15b2,
|
||||||
|
0x67afd1adb5a89eac, 0xc44c75ee32f0038e, 0x2101790f365c0967, 0x76415c64a222fc4a,
|
||||||
|
0x579929249a1e577a, 0xe4762fc41fdbf750, 0xea52198e57dfcdcc, 0xe2535aafe30b4281,
|
||||||
|
0xcb1a1bd6c77c9056, 0x5a1aa9bfc4612a62, 0x15a728aef8943eb5, 0x2f8f09738a8ec8d9,
|
||||||
|
0x200f3dec9fac8074, 0x0fa9a7b1e0d318df, 0x06c0804ffd0d8e3a, 0x630cbc412669dd25,
|
||||||
|
0x10e34f85f4b10285, 0x2a6fe8164b9b6410, 0xcacb57d857d55810, 0x77f8a3a36ff11b46,
|
||||||
|
0x66af517e0dc3003e, 0x76c073c789b4009a, 0x853230dbb529f22a, 0x1e9e9c09a1f77e56,
|
||||||
|
0x1e871223802ee65d, 0x37fe4588718ff813, 0x10088539f30db464, 0x366f7470b80b72d1,
|
||||||
|
0x33f2634d9a6b31db, 0xd43917751d69ea18, 0xa0f492bc1aa7b8de, 0x3f94e5a8054edd20,
|
||||||
|
0xedfd6e25eb8b1dbf, 0x759517a54f196a56, 0xe81d5006ec7b6b17, 0x8dd8385fa894a6b7,
|
||||||
|
0x45f4d5467b0d6f91, 0xa1f894699de22bc8, 0x33829d09ef93e0fe, 0x3e29e250caed603c,
|
||||||
|
0xf7382cba7f63a45e, 0x970f95412bb569d1, 0xc7fcea456d356b4b, 0x723042513f3e7a57,
|
||||||
|
0x17ae7688de3596f1, 0x27ac1fcd7cd23c1a, 0xf429beeb78b3f71f, 0xd0780692fb93a3f9,
|
||||||
|
0x9f507e28a7c9842f, 0x56001ad536e433ae, 0x7e1dd1ecf58be306, 0x15fee353aa233fc6,
|
||||||
|
0xb033a0730b7638e8, 0xeb593ad6bd2406d1, 0x7c86502574d0f133, 0xce3b008d4ccb4be7,
|
||||||
|
0xf8566e3d383594c8, 0xb2c261e9b7af4429, 0xf685e7e253799dbb, 0x05d33ed60a494cbc,
|
||||||
|
0xeaf88d55a4cb0d1a, 0x3ee9368a902415a1, 0x8980fe6a8493a9a4, 0x358ed008cb448631,
|
||||||
|
0xd0cb7e37b46824b8, 0xe9bc375c0bc94f84, 0xea0bf1d8e6b55bb3, 0xb66a60d0f9f6f297,
|
||||||
|
0x66db2cc4807b3758, 0x7e4e014afbca8b4d, 0xa5686a4938b0c730, 0xa5f0d7353d623316,
|
||||||
|
0x26e38c349242d5e8, 0xeeefa80a29858e30, 0x8915cb912aa67386, 0x4b957a47bfc420d4,
|
||||||
|
0xbb53d051a895f7e1, 0x09f5e3235f6911ce, 0x416b98e695cfb7ce, 0x97a08183344c5c86,
|
||||||
|
0xbf68e0791839a861, 0xea05dde59ed3ed56, 0x0ca732280beda160, 0xac748ed62fe7f4e2,
|
||||||
|
0xc686da075cf6e151, 0xe1ba5658f4af05c8, 0xe9ff09fbeb67cc35, 0xafaea9470323b28d,
|
||||||
|
0x0291e8db5bb0ac2a, 0x342072a9bbee77ae, 0x03147eed6b3d0a9c, 0x21379d4de31dbadb,
|
||||||
|
0x2388d965226fb986, 0x52c96988bfebabfa, 0xa6fc29896595bc2d, 0x38fa4af70aa46b8b,
|
||||||
|
0xa688dd13939421ee, 0x99d5275d9b1415da, 0x453d31bb4fe73631, 0xde51debc1fbe3356,
|
||||||
|
0x75a3c847a06c622f, 0xe80e32755d272579, 0x5444052250d8ec0d, 0x8f17dfda19580a3b,
|
||||||
|
0xf6b3e9363a185e42, 0x7a42adec6868732f, 0x32cb6a07629203a2, 0x1eca8957defe56d9,
|
||||||
|
0x9fa85e4bc78ff9ed, 0x20ff07224a499ca7, 0x3fa6295ff9682c70, 0xe3d5b1e3ce993eff,
|
||||||
|
0xa341209362e0b79a, 0x64bd9eae5712ffe8, 0xceebb537babbd12a, 0x5586ef404315954f,
|
||||||
|
0x46c3085c938ab51a, 0xa82ccb9199907cee, 0x8c51b6690a3523c8, 0xc4dbd4c9ae518332,
|
||||||
|
0x979898dbb23db7b2, 0x1b5b585e6f672a9d, 0xce284da7c4903810, 0x841166e8bb5f1c4f,
|
||||||
|
0xb7d884a3fceca7d0, 0xa76468f5a4572374, 0xc10c45f49ee9513d, 0x68f9a5663c1908c9,
|
||||||
|
0x0095a13476a6339d, 0xd1d7516ffbe9c679, 0xfd94ab0c9726f938, 0x627468bbdb27c959,
|
||||||
|
0xedc3f8988e4a8c9a, 0x58efd33f0dfaa499, 0x21e37d7e2ef4ac8b, 0x297f9ab5586259c6,
|
||||||
|
0xda3ba4dc6cb9617d, 0xae11d8d9de2284d2, 0xcfeed88cb3729865, 0xefc2f9e4f03e2633,
|
||||||
|
0x8226393e8f0855a4, 0xd6e25fd7acf3a767, 0x435784c3bfd6d14a, 0xf97142e6343fe757,
|
||||||
|
0xd73b9fe826352f85, 0x6c3ac444b5b2bd76, 0xd8e88f3e9fd4a3fd, 0x31e50875c36f3460,
|
||||||
|
0xa824f1bf88cf4d44, 0x54a4d2c8f5f25899, 0xbff254637ce3b1e6, 0xa02cfe92561b3caa,
|
||||||
|
0x7bedb4edee9f0af7, 0x879c0620ac49a102, 0xa12c4ccd23b332e7, 0x09a5ff47bf94ed1e,
|
||||||
|
0x7b62f43cd3046fa0, 0xaa3af0476b9c2fb9, 0x22e55301abebba8e, 0x3a6035c42747bd58,
|
||||||
|
0x1705373106c8ec07, 0xb1f660de828d0628, 0x065fe82d89ca563d, 0xf555c2d8074d516d,
|
||||||
|
0x6bb6c186b423ee99, 0x54a807be6f3120a8, 0x8a3c7fe2f88860b8, 0xbeffc344f5118e81,
|
||||||
|
0xd686e80b7d1bd268, 0x661aef4ef5e5e88b, 0x5bf256c654cd1dda, 0x9adb1ab85d7640f4,
|
||||||
|
0x68449238920833a2, 0x843279f4cebcb044, 0xc8710cdefa93f7bb, 0x236943294538f3e6,
|
||||||
|
0x80d7d136c486d0b4, 0x61653956b28851d3, 0x3f843be9a9a956b5, 0xf73cfbbf137987e5,
|
||||||
|
0xcf0cb6dee8ceac2c, 0x50c401f52f185cae, 0xbdbe89ce735c4c1c, 0xeef3ade9c0570bc7,
|
||||||
|
0xbe8b066f8f64cbf6, 0x5238d6131705dcb9, 0x20219086c950e9f6, 0x634468d9ed74de02,
|
||||||
|
0x0aba4b3d705c7fa5, 0x3374416f725a6672, 0xe7378bdf7beb3bc6, 0x0f7b6a1b1cee565b,
|
||||||
|
0x234e4c41b0c33e64, 0x4efa9a0c3f21fe28, 0x1167fc551643e514, 0x9f81a69d3eb01fa4,
|
||||||
|
0xdb75c22b12306ed0, 0xe25055d738fc9686, 0x9f9f167a3f8507bb, 0x195f8336d3fbe4d3,
|
||||||
|
0x8442b6feffdcb6f6, 0x1e07ed24746ffde9, 0x140e31462d555266, 0x8bd0ce515ae1406e,
|
||||||
|
0x2c0be0042b5584b3, 0x35a23d0e15d45a60, 0xc14f1ba147d9bc83, 0xbbf168691264b23f,
|
||||||
|
0xad2cc7b57e589ade, 0x9501963154c7815c, 0x9664afa6b8d67d47, 0x7f9e5101fea0a81c,
|
||||||
|
0x45ecffb610d25bfd, 0x3157f7aecf9b6ab3, 0xc43ca6f88d87501d, 0x9576ff838dee38dc,
|
||||||
|
0x93f21afe0ce1c7d7, 0xceac699df343d8f9, 0x2fec49e29f03398d, 0x8805ccd5730281ed,
|
||||||
|
0xf9fc16fc750a8e59, 0x35308cc771adf736, 0x4a57b7c9ee2b7def, 0x03a4c6cdc937a02a,
|
||||||
|
0x6c9a8a269fc8c4fc, 0x4681decec7a03f43, 0x342eecded1353ef9, 0x8be0552d8413a867,
|
||||||
|
0xc7b4ac51beda8be8, 0xebcc64fb719842c0, 0xde8e4c7fb6d40c1c, 0xcc8263b62f9738b1,
|
||||||
|
0xd3cfc0f86511929a, 0x466024ce8bb226ea, 0x459ff690253a3c18, 0x98b27e9d91284c9c,
|
||||||
|
0x75c3ae8aa3af373d, 0xfbf8f8e79a866ffc, 0x32327f59d0662799, 0x8228b57e729e9830,
|
||||||
|
0x065ceb7a18381b58, 0xd2177671a31dc5ff, 0x90cd801f2f8701f9, 0x9d714428471c65fe,
|
||||||
|
}
|
||||||
+354
-18
@@ -1,56 +1,392 @@
|
|||||||
|
// Package cli implements the vaultik command-line interface: cobra
|
||||||
|
// commands, fx application wiring, and process-level concerns such as
|
||||||
|
// signal handling and the PID lock.
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
"github.com/adrg/xdg"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
"github.com/spf13/cobra"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
"go.uber.org/dig"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppOptions contains common options for creating the fx application
|
// shutdownTimeout bounds how long a signal-triggered graceful shutdown
|
||||||
|
// may take before we give up.
|
||||||
|
const shutdownTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// lockMode says whether a command mutates persistent state — the local
|
||||||
|
// index database or the remote store — and so must hold the process-wide
|
||||||
|
// PID lock, or only reads that state and may run alongside a mutator.
|
||||||
|
type lockMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// mutating commands (snapshot create, snapshot purge, snapshot remove,
|
||||||
|
// prune, remote nuke) write the local index or the remote store. They
|
||||||
|
// hold the PID lock so that at most one runs at a time.
|
||||||
|
mutating lockMode = iota
|
||||||
|
// readOnly commands (info, snapshot list, snapshot verify, remote info,
|
||||||
|
// snapshot restore) do not write the local index or the remote store,
|
||||||
|
// so they run without the lock and are never blocked by a running
|
||||||
|
// mutator. restore writes only to the target directory it is given.
|
||||||
|
readOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppOptions contains common options for creating and running the fx
|
||||||
|
// application: the configuration file path, logging options, additional fx
|
||||||
|
// modules and invocations, and whether the command mutates persistent
|
||||||
|
// state (which decides whether it takes the PID lock).
|
||||||
type AppOptions struct {
|
type AppOptions struct {
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
|
LogOptions log.Options
|
||||||
Modules []fx.Option
|
Modules []fx.Option
|
||||||
Invokes []fx.Option
|
Invokes []fx.Option
|
||||||
|
Mode lockMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new fx application with common modules
|
// setupGlobals records the startup time and, when an output-suppression
|
||||||
|
// flag is active, marks the UI writer quiet so that Begin/Complete/
|
||||||
|
// Info/Notice/Detail/Progress are silenced. Warning and Error are NOT
|
||||||
|
// silenced — per the documented convention that --quiet suppresses
|
||||||
|
// non-error output only. The startup banner is printed by Entry
|
||||||
|
// before cobra parses arguments, gated by the same arg-level check.
|
||||||
|
//
|
||||||
|
// --json quiets the UI here too, because stdout then carries a JSON
|
||||||
|
// document and human narration would corrupt it. Unlike Quiet it does
|
||||||
|
// not lower the stderr log level (issue #112), so --verbose/--debug
|
||||||
|
// still surface diagnostics alongside the document.
|
||||||
|
func setupGlobals(
|
||||||
|
lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.Options,
|
||||||
|
) {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
g.StartTime = time.Now().UTC()
|
||||||
|
|
||||||
|
if opts.Cron || opts.Quiet || opts.JSON {
|
||||||
|
v.UI.SetQuiet(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStartupBanner prints the two-line application banner followed by a
|
||||||
|
// blank line. Used both from the fx hook (for subcommand invocations) and
|
||||||
|
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
||||||
|
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
|
||||||
|
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.",
|
||||||
|
globals.Appname, globals.Version, globals.Author,
|
||||||
|
shortCommit, globals.CommitDate,
|
||||||
|
startTime.Format(time.RFC3339))
|
||||||
|
w.Bannerf("%s", globals.Homepage)
|
||||||
|
w.Bannerf("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApp creates a new fx application with common modules.
|
||||||
|
// It sets up the base modules (config, database, logging, globals) and
|
||||||
|
// combines them with any additional modules specified in the options.
|
||||||
|
// The returned fx.App is ready to be started with RunApp.
|
||||||
func NewApp(opts AppOptions) *fx.App {
|
func NewApp(opts AppOptions) *fx.App {
|
||||||
baseModules := []fx.Option{
|
baseModules := []fx.Option{
|
||||||
fx.Supply(config.ConfigPath(opts.ConfigPath)),
|
fx.Supply(config.Path(opts.ConfigPath)),
|
||||||
|
fx.Supply(opts.LogOptions),
|
||||||
fx.Provide(globals.New),
|
fx.Provide(globals.New),
|
||||||
|
fx.Provide(log.New),
|
||||||
config.Module,
|
config.Module,
|
||||||
database.Module,
|
database.Module,
|
||||||
|
log.Module,
|
||||||
|
storage.Module,
|
||||||
|
snapshot.Module,
|
||||||
|
fx.Provide(vaultik.New),
|
||||||
|
fx.Invoke(setupGlobals),
|
||||||
fx.NopLogger,
|
fx.NopLogger,
|
||||||
}
|
}
|
||||||
|
|
||||||
allOptions := append(baseModules, opts.Modules...)
|
capacity := len(baseModules) + len(opts.Modules) + len(opts.Invokes)
|
||||||
|
allOptions := make([]fx.Option, 0, capacity)
|
||||||
|
allOptions = append(allOptions, baseModules...)
|
||||||
|
allOptions = append(allOptions, opts.Modules...)
|
||||||
allOptions = append(allOptions, opts.Invokes...)
|
allOptions = append(allOptions, opts.Invokes...)
|
||||||
|
|
||||||
return fx.New(allOptions...)
|
return fx.New(allOptions...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunApp starts and stops the fx application within the given context
|
// startupError carries a startup failure message that has been cleaned
|
||||||
|
// of fx dependency-injection noise. A distinct type (rather than
|
||||||
|
// errors.New) keeps the dynamic message out of err113's sight while
|
||||||
|
// preserving the exact user-facing text.
|
||||||
|
type startupError struct {
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *startupError) Error() string {
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanStartupError strips fx's dependency-injection call-chain noise from
|
||||||
|
// startup errors. fx wraps the underlying error with messages like
|
||||||
|
//
|
||||||
|
// could not build arguments for function "X" (file:line): failed to build T:
|
||||||
|
// could not build arguments for function "Y" (file:line): failed to build U:
|
||||||
|
// received non-nil error from function "Z" (file:line): <real error>
|
||||||
|
//
|
||||||
|
// Users care about the real error, not the DI plumbing. dig.RootCause,
|
||||||
|
// from the library fx is built on, unwraps those layers and returns the
|
||||||
|
// real error whole. Cutting the text at "): " instead would also cut a
|
||||||
|
// real error that contains it, such as the blob_size_limit one.
|
||||||
|
func cleanStartupError(err error) error {
|
||||||
|
return &startupError{msg: dig.RootCause(err).Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||||
|
// then stops it. The app is asked to stop either by an OS interrupt
|
||||||
|
// (SIGINT/SIGTERM — fx installs its own handler when app.Wait is called) or,
|
||||||
|
// on normal completion, by the finished operation calling
|
||||||
|
// Shutdowner.Shutdown(); both arrive on the app.Wait channel.
|
||||||
|
//
|
||||||
|
// Stopping runs the fx OnStop hooks, and RunApp does not return until Stop
|
||||||
|
// returns. On an interrupt the operation's OnStop hook cancels the running
|
||||||
|
// command and waits for it to unwind — removing its decrypted scratch files —
|
||||||
|
// so the process cannot proceed to exit mid-cleanup (issue #159). Waiting for
|
||||||
|
// Stop before returning is what makes that hook effective: routing the
|
||||||
|
// interrupt through app.Stop and not returning until it completes is required,
|
||||||
|
// because fx also fires the app.Wait channel on the signal, and an earlier
|
||||||
|
// version returned on that alone — unwinding to os.Exit while the concurrent
|
||||||
|
// cleanup still ran. The stop is bounded by shutdownTimeout. Returns an error
|
||||||
|
// if startup fails.
|
||||||
func RunApp(ctx context.Context, app *fx.App) error {
|
func RunApp(ctx context.Context, app *fx.App) error {
|
||||||
if err := app.Start(ctx); err != nil {
|
err := app.Start(ctx)
|
||||||
return fmt.Errorf("failed to start app: %w", err)
|
if err != nil {
|
||||||
|
return cleanStartupError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until an interrupt or the finished operation's
|
||||||
|
// Shutdowner.Shutdown() arrives, then stop the app in this goroutine so we
|
||||||
|
// return only after its OnStop hooks — including the operation's cleanup
|
||||||
|
// wait — have run. Detach the stop from ctx's cancellation but keep its
|
||||||
|
// values, and bound it by shutdownTimeout.
|
||||||
|
<-app.Wait()
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(
|
||||||
|
context.WithoutCancel(ctx), shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = app.Stop(shutdownCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error during shutdown", "error", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
if err := app.Stop(ctx); err != nil {
|
|
||||||
fmt.Printf("error stopping app: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for context cancellation
|
|
||||||
<-ctx.Done()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithApp is a helper that creates and runs an fx app with the given options
|
// errReported marks a failure the operation has already shown the user
|
||||||
|
// (or, under `snapshot verify --json`, put in its document). Entry
|
||||||
|
// turns it into a non-zero exit status without printing anything
|
||||||
|
// further, so the error line is not doubled. It flows up from
|
||||||
|
// RunOperation through cobra to Entry.
|
||||||
|
var errReported = errors.New("operation failed")
|
||||||
|
|
||||||
|
// errInterrupted marks an operation that SIGINT or SIGTERM stopped
|
||||||
|
// before it finished. Entry shows it and returns exitCodeInterrupted.
|
||||||
|
var errInterrupted = errors.New("interrupted before the command finished")
|
||||||
|
|
||||||
|
// RunOperation runs op against the Vaultik instance inside the fx app
|
||||||
|
// and turns a failure into a returned error rather than an os.Exit from
|
||||||
|
// within the goroutine. An os.Exit there skipped main's deferred
|
||||||
|
// profile writers -- so profiling a failing command yielded a truncated
|
||||||
|
// profile (issue #75) -- and RunWithApp's PID-lock release, and denied
|
||||||
|
// the app any graceful shutdown; returning the error to the top runs
|
||||||
|
// all three.
|
||||||
|
//
|
||||||
|
// op runs in a goroutine so OnStart returns promptly and an interrupt
|
||||||
|
// can still cancel through OnStop; when it finishes, success or failure,
|
||||||
|
// it triggers shutdown, which is what lets RunWithApp return. On an
|
||||||
|
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
||||||
|
// op's cleanup (removing decrypted scratch files) runs before the
|
||||||
|
// process exits; the wait is bounded by shutdownTimeout. report is
|
||||||
|
// called with a failure so the caller can show it to the user before
|
||||||
|
// it becomes errReported.
|
||||||
|
//
|
||||||
|
// The run counts as interrupted unless op returned, without an
|
||||||
|
// interrupt having cancelled it, before RunWithApp returned. An
|
||||||
|
// interrupted op is not reported, whatever it returned; RunOperation
|
||||||
|
// returns errInterrupted instead.
|
||||||
|
func RunOperation(
|
||||||
|
ctx context.Context, opts AppOptions,
|
||||||
|
op func(v *vaultik.Vaultik) error, report func(err error),
|
||||||
|
) error {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
finished bool // op returned before any interrupt cancelled it
|
||||||
|
failed bool // op finished with an error
|
||||||
|
)
|
||||||
|
|
||||||
|
opts.Invokes = append(opts.Invokes,
|
||||||
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
|
var stop func(context.Context) bool
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
stop = v.StartOperation(func() {
|
||||||
|
err := op(v)
|
||||||
|
|
||||||
|
// Only stop, called from OnStop below, cancels the
|
||||||
|
// Vaultik context, so a live context means no
|
||||||
|
// interrupt cancelled op. Check the context, not
|
||||||
|
// err: an interrupted op need not return
|
||||||
|
// context.Canceled (`snapshot verify --json`
|
||||||
|
// returns a verification failure).
|
||||||
|
if v.Context().Err() == nil {
|
||||||
|
if err != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
finished = true
|
||||||
|
failed = err != nil
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopErr := v.Shutdowner.Shutdown()
|
||||||
|
if stopErr != nil {
|
||||||
|
log.Error("Failed to shutdown", "error", stopErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// On an interrupt, cancel the operation and wait for it to
|
||||||
|
// unwind so its cleanup defers (which remove decrypted
|
||||||
|
// scratch files from the temp directory) run before the
|
||||||
|
// process exits. The wait is bounded by ctx, the existing
|
||||||
|
// shutdownTimeout.
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
if !stop(ctx) {
|
||||||
|
log.Warn("Shutdown timed out before the operation " +
|
||||||
|
"finished; decrypted temporary files may remain")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
err := RunWithApp(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithApp returns only after the app was asked to stop, either by
|
||||||
|
// an interrupt or by the goroutine's Shutdown call. When op finished
|
||||||
|
// without being cancelled, the goroutine set finished before that
|
||||||
|
// call. So if finished is unset here, an interrupt stopped the app,
|
||||||
|
// and op either returned after it was cancelled or is still running
|
||||||
|
// because the shutdown timed out.
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !finished:
|
||||||
|
return errInterrupted
|
||||||
|
case failed:
|
||||||
|
return errReported
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVaultikApp runs the standard single-operation command lifecycle
|
||||||
|
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
||||||
|
// resolve the config, then run op against the Vaultik instance through
|
||||||
|
// RunOperation, reporting a failure prefixed with failMsg on stderr. mode
|
||||||
|
// says whether the command takes the PID lock. jsonOutput marks a command
|
||||||
|
// whose stdout is a JSON document: it quiets the UI but, unlike Quiet,
|
||||||
|
// leaves the stderr log level alone.
|
||||||
|
func runVaultikApp(
|
||||||
|
cmd *cobra.Command, mode lockMode, jsonOutput bool,
|
||||||
|
failMsg string, op func(v *vaultik.Vaultik) error,
|
||||||
|
) error {
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
JSON: jsonOutput,
|
||||||
|
},
|
||||||
|
Mode: mode,
|
||||||
|
}, op, func(err error) {
|
||||||
|
log.Error(failMsg, "error", err)
|
||||||
|
ReportErrorf("%s: %v", failMsg, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithApp is a helper that creates and runs an fx app with the given options.
|
||||||
|
// It combines NewApp and RunApp into a single convenient function. This is the
|
||||||
|
// preferred way to run CLI commands that need the full application context.
|
||||||
|
// A mutating command takes the process-wide PID lock before starting so that
|
||||||
|
// only one runs at a time; a read-only command runs without it and is not
|
||||||
|
// blocked while a mutator holds the lock (opts.Mode).
|
||||||
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
||||||
|
release, err := acquireLockIfMutating(opts.Mode,
|
||||||
|
filepath.Join(xdg.DataHome, "vaultik"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer release()
|
||||||
|
|
||||||
app := NewApp(opts)
|
app := NewApp(opts)
|
||||||
|
|
||||||
return RunApp(ctx, app)
|
return RunApp(ctx, app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acquireLockIfMutating takes the process-wide PID lock in lockDir for a
|
||||||
|
// mutating command and returns a function that releases it. A read-only
|
||||||
|
// command takes no lock, so it returns a no-op release and is never blocked
|
||||||
|
// while a mutator holds the lock. ErrAlreadyRunning (another mutator holds
|
||||||
|
// the lock) is surfaced as a "cannot start" error.
|
||||||
|
func acquireLockIfMutating(mode lockMode, lockDir string) (func(), error) {
|
||||||
|
if mode != mutating {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lock, err := pidlock.Acquire(lockDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||||
|
return nil, fmt.Errorf("cannot start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to acquire lock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
err := lock.Release()
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to release PID lock", "error", err)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCleanStartupError starts a real fx app whose config has a
|
||||||
|
// blob_size_limit below the minimum. fx puts annotations naming the
|
||||||
|
// functions it was building in front of the config error; only those
|
||||||
|
// may be removed, although the config error itself contains "): ".
|
||||||
|
func TestCleanStartupError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("snapshots:\n"+
|
||||||
|
" test:\n"+
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n"+
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"+
|
||||||
|
"chunk_size: 10MB\n"+
|
||||||
|
"blob_size_limit: 30MB\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Supply(config.Path(path)),
|
||||||
|
config.Module,
|
||||||
|
fx.Invoke(func(*config.Config) {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
err = app.Start(t.Context())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("app started; want a blob_size_limit error")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "failed to load config: invalid config: blob_size_limit must " +
|
||||||
|
"be at least the largest chunk the chunker can emit (chunk_size " +
|
||||||
|
"times the FastCDC size spread): need at least 40000000 bytes"
|
||||||
|
|
||||||
|
got := cleanStartupError(err).Error()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
|
||||||
|
// shown unchanged.
|
||||||
|
func TestCleanStartupErrorNotFromFx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//nolint:err113 // a one-off error standing in for a startup failure
|
||||||
|
got := cleanStartupError(errors.New("plain error")).Error()
|
||||||
|
if got != "plain error" {
|
||||||
|
t.Errorf("got %q, want %q", got, "plain error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLockScopedToMutatingCommands proves the partition the PID lock now
|
||||||
|
// enforces: a read-only command runs while a mutator holds the lock, and
|
||||||
|
// two mutating commands still mutually exclude.
|
||||||
|
func TestLockScopedToMutatingCommands(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lockDir := filepath.Join(t.TempDir(), "vaultik")
|
||||||
|
|
||||||
|
// A mutating command takes the process-wide lock.
|
||||||
|
releaseMutator, err := acquireLockIfMutating(mutating, lockDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mutating command could not acquire lock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read-only command runs to completion even while the lock is held.
|
||||||
|
releaseReader, err := acquireLockIfMutating(readOnly, lockDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read-only command was blocked by held lock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseReader()
|
||||||
|
|
||||||
|
// A second mutating command is refused while the first holds the lock.
|
||||||
|
_, err = acquireLockIfMutating(mutating, lockDir)
|
||||||
|
if !errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||||
|
t.Fatalf("second mutating command was not excluded, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the first mutator releases, another mutating command may run.
|
||||||
|
releaseMutator()
|
||||||
|
|
||||||
|
release, err := acquireLockIfMutating(mutating, lockDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mutating command could not acquire released lock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
}
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BackupOptions contains options for the backup command
|
|
||||||
type BackupOptions struct {
|
|
||||||
ConfigPath string
|
|
||||||
Daemon bool
|
|
||||||
Cron bool
|
|
||||||
Prune bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBackupCommand creates the backup command
|
|
||||||
func NewBackupCommand() *cobra.Command {
|
|
||||||
opts := &BackupOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "backup",
|
|
||||||
Short: "Perform incremental backup",
|
|
||||||
Long: `Backup configured directories using incremental deduplication and encryption.
|
|
||||||
|
|
||||||
Config is located at /etc/vaultik/config.yml, but can be overridden by specifying
|
|
||||||
a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// If --config not specified, check environment variable
|
|
||||||
if opts.ConfigPath == "" {
|
|
||||||
opts.ConfigPath = os.Getenv("VAULTIK_CONFIG")
|
|
||||||
}
|
|
||||||
// If still not specified, use default
|
|
||||||
if opts.ConfigPath == "" {
|
|
||||||
defaultConfig := "/etc/vaultik/config.yml"
|
|
||||||
if _, err := os.Stat(defaultConfig); err == nil {
|
|
||||||
opts.ConfigPath = defaultConfig
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("no config file specified, VAULTIK_CONFIG not set, and %s not found", defaultConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runBackup(cmd.Context(), opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.ConfigPath, "config", "", "Path to config file")
|
|
||||||
cmd.Flags().BoolVar(&opts.Daemon, "daemon", false, "Run in daemon mode with inotify monitoring")
|
|
||||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)")
|
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false, "Delete all previous snapshots and unreferenced blobs after backup")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBackup(ctx context.Context, opts *BackupOptions) error {
|
|
||||||
return RunWithApp(ctx, AppOptions{
|
|
||||||
ConfigPath: opts.ConfigPath,
|
|
||||||
Invokes: []fx.Option{
|
|
||||||
fx.Invoke(func(g *globals.Globals, cfg *config.Config, repos *database.Repositories) error {
|
|
||||||
// TODO: Implement backup logic
|
|
||||||
fmt.Printf("Running backup with config: %s\n", opts.ConfigPath)
|
|
||||||
fmt.Printf("Version: %s, Commit: %s\n", g.Version, g.Commit)
|
|
||||||
fmt.Printf("Index path: %s\n", cfg.IndexPath)
|
|
||||||
if opts.Daemon {
|
|
||||||
fmt.Println("Running in daemon mode")
|
|
||||||
}
|
|
||||||
if opts.Cron {
|
|
||||||
fmt.Println("Running in cron mode")
|
|
||||||
}
|
|
||||||
if opts.Prune {
|
|
||||||
fmt.Println("Pruning enabled - will delete old snapshots after backup")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configFileMode is the permission set for freshly written config files;
|
||||||
|
// configs may hold S3 credentials, so keep them owner-only.
|
||||||
|
const configFileMode = 0o600
|
||||||
|
|
||||||
|
// configSetArgs is the argument count of `config set <key> <value>`.
|
||||||
|
const configSetArgs = 2
|
||||||
|
|
||||||
|
// configDirMode is the permission set for created config directories;
|
||||||
|
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
||||||
|
const configDirMode = 0o755
|
||||||
|
|
||||||
|
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
||||||
|
// so `config set` writes the file back with the same indentation rather than
|
||||||
|
// yaml.Marshal's 4-space default.
|
||||||
|
const configYAMLIndent = 2
|
||||||
|
|
||||||
|
// yamlStringTag is YAML's tag for a string scalar.
|
||||||
|
const yamlStringTag = "!!str"
|
||||||
|
|
||||||
|
var (
|
||||||
|
errConfigExists = errors.New("config file already exists")
|
||||||
|
errEmptyConfig = errors.New("empty config file")
|
||||||
|
errKeyNotFound = errors.New("key not found")
|
||||||
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
||||||
|
errIndexOutOfRange = errors.New("index out of range")
|
||||||
|
errNotMapOrList = errors.New("key is not a map or list")
|
||||||
|
errWouldNotLoad = errors.New(
|
||||||
|
"vaultik would not load the config after this change, " +
|
||||||
|
"so the file was not changed")
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultConfigTemplate = `# vaultik configuration
|
||||||
|
# Documentation: https://sneak.berlin/go/vaultik
|
||||||
|
|
||||||
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Age recipient public keys for encryption. snapshot create needs at least
|
||||||
|
# one; listing, verifying and restoring do not, so a machine that only
|
||||||
|
# restores can leave this empty.
|
||||||
|
# Backups are encrypted to ALL listed recipients; any one of the corresponding
|
||||||
|
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
||||||
|
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
||||||
|
# earlier recipients, so a newly added key cannot restore them on its own (see
|
||||||
|
# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair and add its
|
||||||
|
# public key with:
|
||||||
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
|
# vaultik config set age_recipients.0 age1...
|
||||||
|
age_recipients: []
|
||||||
|
|
||||||
|
# Named snapshots. Each snapshot backs up one or more paths and can have its
|
||||||
|
# own exclude patterns in addition to the global excludes below.
|
||||||
|
#
|
||||||
|
# Exclude pattern semantics:
|
||||||
|
# - Patterns starting with / are anchored to the snapshot path root
|
||||||
|
# (e.g. "/Library/Caches" matches only ~/Library/Caches in a ~ snapshot)
|
||||||
|
# - Patterns without a leading / match anywhere in the tree
|
||||||
|
# (e.g. ".cache" matches any directory named .cache at any depth)
|
||||||
|
# - Globs are supported: *, **, ?
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
exclude:
|
||||||
|
# Trash, temp, and filesystem metadata
|
||||||
|
- "/.Trash"
|
||||||
|
- "/.Trashes"
|
||||||
|
- "/.fseventsd"
|
||||||
|
- "/.Spotlight-V100"
|
||||||
|
- "/.TemporaryItems"
|
||||||
|
- "/tmp"
|
||||||
|
- "/.rnd"
|
||||||
|
- ".DS_Store"
|
||||||
|
# Caches and package manager state (rebuildable)
|
||||||
|
- ".cache"
|
||||||
|
- ".bundle"
|
||||||
|
- "/.cpan/build"
|
||||||
|
- "/.cpan/sources"
|
||||||
|
- "/.gradle/caches"
|
||||||
|
- "/.dropbox"
|
||||||
|
- "/.minikube/cache"
|
||||||
|
- "/.local/share/containers/podman/machine"
|
||||||
|
- "/.persepolis"
|
||||||
|
- "/Library/Caches"
|
||||||
|
- "/Library/Logs"
|
||||||
|
- "/Library/Cookies"
|
||||||
|
- "/Library/Metadata"
|
||||||
|
- "/Library/Suggestions"
|
||||||
|
- "/Library/PubSub"
|
||||||
|
- "/Library/Homebrew"
|
||||||
|
- "/Library/Developer"
|
||||||
|
- "/Library/Google/GoogleSoftwareUpdate"
|
||||||
|
- "/Library/Preferences/Macromedia/Flash Player"
|
||||||
|
- "/Library/Preferences/SDMHelpData"
|
||||||
|
- "/Library/VoiceTrigger/SAT"
|
||||||
|
# Language/toolchain package caches (rebuildable from registries)
|
||||||
|
- "/.npm"
|
||||||
|
- "/.cargo/registry"
|
||||||
|
- "/.cargo/git"
|
||||||
|
- "/.rustup/toolchains"
|
||||||
|
- "/go/pkg/mod"
|
||||||
|
- "/.m2/repository"
|
||||||
|
- "/.vagrant.d/boxes"
|
||||||
|
- "node_modules"
|
||||||
|
- "__pycache__"
|
||||||
|
- ".venv"
|
||||||
|
# Virtual machine disk images (huge; remove these lines to back them up)
|
||||||
|
- "/Parallels"
|
||||||
|
- "/Virtual Machines.localized"
|
||||||
|
- "/VirtualBox VMs"
|
||||||
|
- "/.orbstack"
|
||||||
|
- "/Library/Containers/com.utmapp.UTM"
|
||||||
|
# Downloaded LLM models (huge, re-downloadable)
|
||||||
|
- "/.ollama/models"
|
||||||
|
- "/.lmstudio/models"
|
||||||
|
# Cloud-synced storage. These are synced to a provider already, and on
|
||||||
|
# modern macOS may contain dataless placeholder files that the backup
|
||||||
|
# would force-download in full.
|
||||||
|
- "/Library/CloudStorage"
|
||||||
|
- "/Library/Mobile Documents"
|
||||||
|
# Android SDK and emulator images (re-downloadable)
|
||||||
|
- "/Library/Android/sdk"
|
||||||
|
- "/.android/avd"
|
||||||
|
# Cloud-synced or restorable-from-server data
|
||||||
|
- "/Library/Mail"
|
||||||
|
- "/Library/Mail Downloads"
|
||||||
|
- "/Library/Safari"
|
||||||
|
- "/Library/Application Support/Evernote"
|
||||||
|
- "/Library/Application Support/MobileSync"
|
||||||
|
- "/Library/Application Support/SyncServices"
|
||||||
|
- "/Library/Application Support/protonmail/bridge/cache"
|
||||||
|
- "/Library/Application Support/Syncthing/index-*"
|
||||||
|
- "/Library/Syncthing/folders"
|
||||||
|
- "/Documents/Dropbox/.dropbox.cache"
|
||||||
|
# Large rebuildable app data (games, media caches, device backups)
|
||||||
|
- "/Applications/Fortnite"
|
||||||
|
- "/Documents/Steam Content"
|
||||||
|
- "/Library/Application Support/Ableton"
|
||||||
|
- "/Library/Application Support/CrossOver Games"
|
||||||
|
- "/Library/Application Support/SecondLife/cache"
|
||||||
|
- "/Library/Application Support/Steam/SteamApps"
|
||||||
|
- "/Library/Containers/com.docker.docker"
|
||||||
|
- "/Library/Group Containers/group.com.apple.secure-control-center-preferences"
|
||||||
|
- "/Library/iTunes/iPad Software Updates"
|
||||||
|
- "/Library/iTunes/iPhone Software Updates"
|
||||||
|
- "/Movies/CacheClip"
|
||||||
|
- "/Movies/ProxyMedia"
|
||||||
|
- "/Music/iTunes/Album Artwork"
|
||||||
|
- "/Pictures/iPod Photo Cache"
|
||||||
|
|
||||||
|
# Third-party applications. OS-provided apps live in /System/Applications
|
||||||
|
# on modern macOS and are never in /Applications, but Apple-installed
|
||||||
|
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
||||||
|
# they are re-downloadable.
|
||||||
|
apps:
|
||||||
|
paths:
|
||||||
|
- /Applications
|
||||||
|
exclude:
|
||||||
|
- ".DS_Store"
|
||||||
|
- "/Safari.app"
|
||||||
|
- "/GarageBand.app"
|
||||||
|
- "/iMovie.app"
|
||||||
|
- "/Keynote.app"
|
||||||
|
- "/Numbers.app"
|
||||||
|
- "/Pages.app"
|
||||||
|
- "/Xcode.app"
|
||||||
|
- "/Spotify.app"
|
||||||
|
- "/Steam.app"
|
||||||
|
- "/VirtualBox.app"
|
||||||
|
- "/Utilities/Adobe Installers"
|
||||||
|
|
||||||
|
# Storage backend (pick ONE of the three forms below).
|
||||||
|
#
|
||||||
|
# S3-compatible:
|
||||||
|
# storage_url: "s3://mybucket/backups?endpoint=s3.example.com®ion=us-east-1"
|
||||||
|
# (also set s3.access_key_id and s3.secret_access_key below)
|
||||||
|
#
|
||||||
|
# Local filesystem:
|
||||||
|
# storage_url: "file:///mnt/backups/vaultik"
|
||||||
|
#
|
||||||
|
# Rclone (requires rclone configured separately):
|
||||||
|
# storage_url: "rclone://myremote/path/to/backups"
|
||||||
|
storage_url: ""
|
||||||
|
|
||||||
|
# ─── S3 CREDENTIALS (required for s3:// storage_url) ────────────────────────
|
||||||
|
|
||||||
|
# s3:
|
||||||
|
# access_key_id: YOUR_ACCESS_KEY
|
||||||
|
# secret_access_key: YOUR_SECRET_KEY
|
||||||
|
# # region: us-east-1 # Default: us-east-1
|
||||||
|
# # part_size: 5MiB # Upload part size, 5MiB to 5GiB. Default: 5MiB
|
||||||
|
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
||||||
|
|
||||||
|
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Global exclude patterns applied to ALL snapshots.
|
||||||
|
# Snapshot-specific excludes are additive.
|
||||||
|
# exclude:
|
||||||
|
# - "*.log"
|
||||||
|
# - "*.tmp"
|
||||||
|
# - ".git"
|
||||||
|
# - "node_modules"
|
||||||
|
|
||||||
|
# Average chunk size for content-defined chunking (FastCDC).
|
||||||
|
# Smaller = better deduplication but more metadata overhead.
|
||||||
|
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
||||||
|
# Default: 10MB
|
||||||
|
# chunk_size: 10MB
|
||||||
|
|
||||||
|
# Maximum blob size before splitting into a new blob.
|
||||||
|
# Must be at least four times chunk_size (the largest chunk the chunker can
|
||||||
|
# emit); a smaller limit would let a single-chunk blob exceed it.
|
||||||
|
# Accepts: 1GB, 10G, 500MB, etc.
|
||||||
|
# Default: 10GB
|
||||||
|
# blob_size_limit: 10GB
|
||||||
|
|
||||||
|
# Zstd compression level (1-19). Higher = better ratio but slower.
|
||||||
|
# Default: 3
|
||||||
|
# compression_level: 3
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it,
|
||||||
|
# and how old every object in a directory under metadata/ with no manifest
|
||||||
|
# must be before prune deletes that directory. A backup uploads its blobs
|
||||||
|
# and its db.zst.age before its manifest, so on a destination shared
|
||||||
|
# between hosts or users this must be longer than the longest backup run.
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that
|
||||||
|
# stopped before its manifest: that blob keeps the time of its first
|
||||||
|
# upload, so a prune can delete it.
|
||||||
|
# Accepts: 36h, 90m, etc. 0s deletes such blobs and directories at once.
|
||||||
|
# Default: 24h
|
||||||
|
# prune_grace_period: 24h
|
||||||
|
|
||||||
|
# Hostname used in snapshot IDs. Default: system hostname.
|
||||||
|
# hostname: myserver
|
||||||
|
|
||||||
|
# Path to the local SQLite index database.
|
||||||
|
# Default: the platform data directory, e.g.
|
||||||
|
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||||
|
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||||
|
# index_path: /path/to/index.sqlite
|
||||||
|
`
|
||||||
|
|
||||||
|
// NewConfigCommand creates the config command group.
|
||||||
|
func NewConfigCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Manage the configuration file",
|
||||||
|
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newConfigInitCommand())
|
||||||
|
cmd.AddCommand(newConfigEditCommand())
|
||||||
|
cmd.AddCommand(newConfigGetCommand())
|
||||||
|
cmd.AddCommand(newConfigSetCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigInitCommand creates the 'config init' subcommand.
|
||||||
|
func newConfigInitCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Write a default config file",
|
||||||
|
Long: `Creates a default configuration file with commented explanations
|
||||||
|
for the settings it lists. If a config file already exists at the target
|
||||||
|
path, the command refuses to overwrite it. The file is created with mode
|
||||||
|
0600, because it can hold credentials.
|
||||||
|
|
||||||
|
The config is written to the path from --config, $VAULTIK_CONFIG, or
|
||||||
|
config.yml in the platform default config directory
|
||||||
|
(~/Library/Application Support/vaultik/ on macOS, ~/.config/vaultik/ on
|
||||||
|
Linux, /etc/vaultik/ as root).`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
path := configPathForInit()
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("%w: %s", errConfigExists, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
err = os.MkdirAll(dir, configDirMode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A written-confirmation, not scriptable output: route it
|
||||||
|
// through the UI so it is styled and --quiet silences it.
|
||||||
|
out := commandUI(cmd)
|
||||||
|
out.Infof("Config written to %s.", path)
|
||||||
|
out.Infof(
|
||||||
|
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigEditCommand creates the 'config edit' subcommand.
|
||||||
|
func newConfigEditCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "edit",
|
||||||
|
Short: "Open the config file in $EDITOR (vi when it is not set)",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
path, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
editor := os.Getenv("EDITOR")
|
||||||
|
if editor == "" {
|
||||||
|
editor = "vi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run $EDITOR through the shell, as git does, so a value
|
||||||
|
// with arguments such as "code --wait" works. The path
|
||||||
|
// goes in as an argument ("$@"), not into the command
|
||||||
|
// text, so a path with spaces or quotes arrives whole.
|
||||||
|
// sh -c takes the word after the command as $0, which
|
||||||
|
// is why editor appears twice.
|
||||||
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||||
|
ed := exec.CommandContext(cmd.Context(),
|
||||||
|
"sh", "-c", editor+` "$@"`, editor, path)
|
||||||
|
ed.Stdin = os.Stdin
|
||||||
|
ed.Stdout = os.Stdout
|
||||||
|
ed.Stderr = os.Stderr
|
||||||
|
|
||||||
|
return ed.Run()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigGetCommand creates the 'config get' subcommand.
|
||||||
|
func newConfigGetCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "get <key>",
|
||||||
|
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := loadYAMLFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := yamlPathGet(root, strings.Split(args[0], "."))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The value is scriptable output: it must stay machine-plain
|
||||||
|
// (no marker, no color) and is never silenced by --quiet, so it
|
||||||
|
// is written straight to stdout rather than through the UI.
|
||||||
|
w := cmd.OutOrStdout()
|
||||||
|
|
||||||
|
if node.Kind == yaml.ScalarNode {
|
||||||
|
_, _ = fmt.Fprintln(w, node.Value)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(node)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprint(w, string(out))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigSetCommand creates the 'config set' subcommand.
|
||||||
|
func newConfigSetCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "set <key> <value>",
|
||||||
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
||||||
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
||||||
|
the file back, preserving comments and formatting. Missing keys along
|
||||||
|
the path are created: as a list when the next path element is a
|
||||||
|
number, otherwise as a map.
|
||||||
|
|
||||||
|
If the config loads, a change after which it would not, such as
|
||||||
|
compression_level 99, is refused and the file is left unchanged. A
|
||||||
|
config that does not load, such as the one config init writes before
|
||||||
|
storage_url is set or one with a ${ENV:...} reference to a variable
|
||||||
|
not set here, is checked one setting at a time: only a change after
|
||||||
|
which the changed setting cannot be read, such as one giving it the
|
||||||
|
wrong type, is refused.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
vaultik config set storage_url "file:///mnt/backups"
|
||||||
|
vaultik config set storage_url "s3://bucket/prefix?endpoint=host®ion=us-east-1"
|
||||||
|
vaultik config set compression_level 9
|
||||||
|
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
||||||
|
Args: cobra.ExactArgs(configSetArgs),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeConfigSet(commandUI(cmd), path, args[0], args[1])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfigSet applies key=value to the config at path, writes it back
|
||||||
|
// owner-only, and confirms the write by naming just the key through the
|
||||||
|
// UI writer (styled, and silenced by --quiet). The value is never
|
||||||
|
// echoed: it may be a secret such as s3.secret_access_key, and captured
|
||||||
|
// stdout or a pasted terminal would then leak it.
|
||||||
|
func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||||
|
root, err := loadYAMLFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldData, err := marshalConfigYAML(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := strings.Split(key, ".")
|
||||||
|
|
||||||
|
err = yamlPathSet(root, keys, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := marshalConfigYAML(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setting, err := yamlPathOnly(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingData, err := marshalConfigYAML(setting)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkConfigChange(oldData, data, settingData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, data, configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// os.WriteFile does not change the mode of a file that already exists,
|
||||||
|
// so a config that was group- or world-readable stays that way. As it
|
||||||
|
// may hold S3 credentials, tighten it to owner-only after writing.
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr == nil && info.Mode().Perm()&0o044 != 0 {
|
||||||
|
err = os.Chmod(path, configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tightening config file permissions: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Infof("Set %s.", key)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkConfigChange returns the reason vaultik would not load newData, the
|
||||||
|
// config after a change, if the change brings that failure in. If oldData,
|
||||||
|
// the config before it, loads, the new one must load too. A config that
|
||||||
|
// does not load, such as one still being filled in or one with a ${...}
|
||||||
|
// reference that cannot be filled in here, is checked one setting at a
|
||||||
|
// time: settingData, the changed setting on its own, must decode into the
|
||||||
|
// config type.
|
||||||
|
func checkConfigChange(oldData, newData, settingData []byte) error {
|
||||||
|
oldCfg, err := config.Parse(oldData)
|
||||||
|
if err == nil && oldCfg.Validate() == nil {
|
||||||
|
newCfg, err := config.Parse(newData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCfg.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Parse(settingData)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathOnly returns a document holding only the value at the dotted key
|
||||||
|
// path in root, inside the maps and lists that lead to it there.
|
||||||
|
func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||||
|
node, err := yamlPathGet(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range slices.Backward(keys) {
|
||||||
|
parent, err := yamlPathGet(root, keys[:i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.Kind == yaml.SequenceNode {
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.SequenceNode, Content: []*yaml.Node{node},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, node},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{node}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||||
|
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
||||||
|
// would reindent the whole file on the first `config set` despite the promise
|
||||||
|
// to preserve formatting.
|
||||||
|
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
enc := yaml.NewEncoder(&buf)
|
||||||
|
enc.SetIndent(configYAMLIndent)
|
||||||
|
|
||||||
|
err := enc.Encode(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = enc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
||||||
|
// which preserves comments and ordering for round-tripping.
|
||||||
|
func loadYAMLFile(path string) (*yaml.Node, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var root yaml.Node
|
||||||
|
|
||||||
|
err = yaml.Unmarshal(data, &root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty file yields a zero node; normalize to an empty mapping document.
|
||||||
|
if root.Kind == 0 {
|
||||||
|
root = yaml.Node{
|
||||||
|
Kind: yaml.DocumentNode,
|
||||||
|
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathGet navigates a dotted key path through mapping and sequence
|
||||||
|
// nodes and returns the value node. Numeric path components index into
|
||||||
|
// sequences (e.g. "age_recipients.0").
|
||||||
|
func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||||
|
node := root
|
||||||
|
if node.Kind == yaml.DocumentNode {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
return nil, errEmptyConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.Content[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
|
if node.Content[j].Value == key {
|
||||||
|
node = node.Content[j+1]
|
||||||
|
found = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("%w: %s",
|
||||||
|
errKeyNotFound, strings.Join(keys[:i+1], "."))
|
||||||
|
}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
idx, err := strconv.Atoi(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s",
|
||||||
|
errNeedNumericIndex, strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx < 0 || idx >= len(node.Content) {
|
||||||
|
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
|
||||||
|
errIndexOutOfRange, idx, strings.Join(keys[:i], "."),
|
||||||
|
len(node.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.Content[idx]
|
||||||
|
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
|
||||||
|
return nil, fmt.Errorf("%w: %s",
|
||||||
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s",
|
||||||
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathSet navigates a dotted key path, creating missing keys as needed,
|
||||||
|
// and sets the final key to the given scalar value. Numeric path
|
||||||
|
// components index into sequences; an index equal to the sequence length
|
||||||
|
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
||||||
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||||
|
node := root
|
||||||
|
if node.Kind == yaml.DocumentNode {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
node.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.Content[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range keys {
|
||||||
|
last := i == len(keys)-1
|
||||||
|
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
node = yamlSetInMapping(node, keys, i, value, last)
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
next, err := yamlSetInSequence(node, keys, i, value, last)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
node = next
|
||||||
|
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
|
||||||
|
return fmt.Errorf("%w: %s",
|
||||||
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %s",
|
||||||
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// config.Load reads the file through untyped YAML, which turns an
|
||||||
|
// unquoted 00112233 into the number 38043 and 1e5 into 100000. Tagging
|
||||||
|
// a string setting as a string makes the encoder quote such a value.
|
||||||
|
// Other settings stay unquoted, so compression_level 9 is a number.
|
||||||
|
// The encoder refuses to write a value that is not valid UTF-8 as a
|
||||||
|
// string. Left untagged, such a value is written as base64 !!binary and
|
||||||
|
// loads back unchanged.
|
||||||
|
if configKeyIsString(keys) && utf8.ValidString(value) {
|
||||||
|
node.Tag = yamlStringTag
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configKeyIsString reports whether the dotted key names a string in
|
||||||
|
// config.Config, following the fields' yaml tags, as s3.access_key_id and
|
||||||
|
// snapshots.home.exclude.0 do.
|
||||||
|
func configKeyIsString(keys []string) bool {
|
||||||
|
typ := reflect.TypeFor[config.Config]()
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
switch {
|
||||||
|
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
||||||
|
// The key is a snapshot name or a list index.
|
||||||
|
typ = typ.Elem()
|
||||||
|
case typ.Kind() == reflect.Struct:
|
||||||
|
field, ok := yamlField(typ, key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
typ = field.Type
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typ.Kind() == reflect.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlField returns the field of struct type typ whose yaml tag names key.
|
||||||
|
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
||||||
|
for field := range typ.Fields() {
|
||||||
|
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
||||||
|
if name == key {
|
||||||
|
return field, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.StructField{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlSetInMapping resolves (creating if needed) the value node for the
|
||||||
|
// path element keys[i] within a mapping node, setting it to value when it
|
||||||
|
// is the final path element, and returns the node to descend into.
|
||||||
|
func yamlSetInMapping(
|
||||||
|
node *yaml.Node, keys []string, i int, value string, last bool,
|
||||||
|
) *yaml.Node {
|
||||||
|
var valueNode *yaml.Node
|
||||||
|
|
||||||
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
|
if node.Content[j].Value == keys[i] {
|
||||||
|
valueNode = node.Content[j+1]
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if valueNode == nil {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keys[i]}
|
||||||
|
valueNode = newPathNode(keys, i, value)
|
||||||
|
node.Content = append(node.Content, keyNode, valueNode)
|
||||||
|
} else if last {
|
||||||
|
setScalar(valueNode, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlSetInSequence indexes (or appends to) a sequence node using the
|
||||||
|
// numeric path element keys[i], setting the element to value when it is
|
||||||
|
// the final path element, and returns the node to descend into.
|
||||||
|
func yamlSetInSequence(
|
||||||
|
node *yaml.Node, keys []string, i int, value string, last bool,
|
||||||
|
) (*yaml.Node, error) {
|
||||||
|
idx, err := strconv.Atoi(keys[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s",
|
||||||
|
errNeedNumericIndex, strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx < 0 || idx > len(node.Content) {
|
||||||
|
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
|
||||||
|
errIndexOutOfRange, idx, strings.Join(keys[:i], "."),
|
||||||
|
len(node.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx == len(node.Content) {
|
||||||
|
node.Content = append(node.Content, newPathNode(keys, i, value))
|
||||||
|
} else if last {
|
||||||
|
setScalar(node.Content[idx], value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.Content[idx], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPathNode returns the node to create for the missing path element
|
||||||
|
// keys[i]: the value when it is the final path element, otherwise an empty
|
||||||
|
// list when the next path element is a number and an empty map when not.
|
||||||
|
func newPathNode(keys []string, i int, value string) *yaml.Node {
|
||||||
|
if i == len(keys)-1 {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := strconv.Atoi(keys[i+1])
|
||||||
|
if err == nil {
|
||||||
|
return &yaml.Node{Kind: yaml.SequenceNode}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setScalar overwrites a node in place with a plain scalar value.
|
||||||
|
func setScalar(n *yaml.Node, value string) {
|
||||||
|
n.Kind = yaml.ScalarNode
|
||||||
|
n.Tag = ""
|
||||||
|
n.Value = value
|
||||||
|
n.Content = nil
|
||||||
|
n.Style = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// configPathForInit returns the config path to write, checking --config flag,
|
||||||
|
// VAULTIK_CONFIG env, and the platform default.
|
||||||
|
func configPathForInit() string {
|
||||||
|
if rootFlags.ConfigPath != "" {
|
||||||
|
return rootFlags.ConfigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" {
|
||||||
|
return envPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultConfigPath()
|
||||||
|
}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||||
|
// that unmarshals into the Config struct with the expected snapshots.
|
||||||
|
func TestDefaultConfigTemplateParses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var cfg config.Config
|
||||||
|
|
||||||
|
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A placeholder recipient would fail config.Load, so the template
|
||||||
|
// leaves the list empty.
|
||||||
|
if len(cfg.AgeRecipients) != 0 {
|
||||||
|
t.Errorf("expected no age recipients, got %d", len(cfg.AgeRecipients))
|
||||||
|
}
|
||||||
|
|
||||||
|
home, ok := cfg.Snapshots["home"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'home' snapshot in default config")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(home.Paths) == 0 {
|
||||||
|
t.Error("home snapshot should have at least one path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(home.Exclude) == 0 {
|
||||||
|
t.Error("home snapshot should have exclude patterns")
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, ok := cfg.Snapshots["apps"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'apps' snapshot in default config")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" {
|
||||||
|
t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apps.Exclude) == 0 {
|
||||||
|
t.Error("apps snapshot should have exclude patterns")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRecipient is a valid age public key.
|
||||||
|
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
|
||||||
|
// TestConfigSetRecipientOnFreshConfig follows the README's getting started
|
||||||
|
// steps: on the file `config init` writes, `config set age_recipients.0` and
|
||||||
|
// `config set storage_url` give a config that loads with that recipient.
|
||||||
|
// Neither is refused, although the config does not load until both are set;
|
||||||
|
// once it loads, a recipient that is not a valid key is.
|
||||||
|
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "age_recipients.0", testRecipient)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set age_recipients.0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set storage_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != testRecipient {
|
||||||
|
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, testRecipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "age_recipients.0", "notakey")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not a valid recipient") {
|
||||||
|
t.Errorf("config set age_recipients.0 notakey gives error %v, "+
|
||||||
|
"want one saying it is not a valid recipient", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("config.Load after the refused change: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configWithoutRecipients loads, but has no age_recipients key and no
|
||||||
|
// snapshot named work.
|
||||||
|
const configWithoutRecipients = `storage_url: file:///mnt/backups
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
`
|
||||||
|
|
||||||
|
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
|
||||||
|
// set, as a backup job's environment might set it. No test sets it.
|
||||||
|
const configWithUnsetReference = configWithoutRecipients + `s3:
|
||||||
|
secret_access_key: ${ENV:VAULTIK_TEST_UNSET_SECRET}
|
||||||
|
`
|
||||||
|
|
||||||
|
// configWithLevelReference returns configWithoutRecipients with a ${FILE:...}
|
||||||
|
// reference on compression_level to a file holding 9, so that the config
|
||||||
|
// loads once the reference is filled in.
|
||||||
|
func configWithLevelReference(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
levelPath := filepath.Join(t.TempDir(), "level")
|
||||||
|
|
||||||
|
err := os.WriteFile(levelPath, []byte("9"), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configWithoutRecipients +
|
||||||
|
"compression_level: ${FILE:" + levelPath + "}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetIndexUnderMissingKey checks that a list index under a key
|
||||||
|
// the file does not have creates a list, and that the result loads.
|
||||||
|
func TestConfigSetIndexUnderMissingKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
field func(cfg *config.Config) []string
|
||||||
|
}{
|
||||||
|
{"age_recipients.0", testRecipient,
|
||||||
|
func(cfg *config.Config) []string { return cfg.AgeRecipients }},
|
||||||
|
{"snapshots.work.paths.0", "/srv/work",
|
||||||
|
func(cfg *config.Config) []string { return cfg.Snapshots["work"].Paths }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set %s: %v", tt.key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tt.field(cfg)
|
||||||
|
if len(got) != 1 || got[0] != tt.value {
|
||||||
|
t.Errorf("%s gives %v, want [%s]", tt.key, got, tt.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetKeepsFileThatWouldNotLoad checks that config set refuses a
|
||||||
|
// change after which the config would not load, with an error giving the
|
||||||
|
// reason, and leaves the file as it was. On the file `config init` writes,
|
||||||
|
// which does not load until storage_url is set, and on one that cannot be
|
||||||
|
// read here, only a value of the wrong type is refused.
|
||||||
|
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
levelReference := configWithLevelReference(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
config string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
// Index 1 of the empty list the missing key gives.
|
||||||
|
{configWithoutRecipients, "age_recipients.1", testRecipient,
|
||||||
|
"index out of range"},
|
||||||
|
// A list set to a single value.
|
||||||
|
{configWithoutRecipients, "snapshots.home.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
{configWithoutRecipients, "chunk_size", "fast",
|
||||||
|
"invalid size format"},
|
||||||
|
// Values of the right type that fail validation.
|
||||||
|
{configWithoutRecipients, "compression_level", "99",
|
||||||
|
"compression_level must be between 1 and 19"},
|
||||||
|
{configWithoutRecipients, "chunk_size", "1KB",
|
||||||
|
"chunk_size must be at least 1MB"},
|
||||||
|
// With its reference filled in, the config loads.
|
||||||
|
{levelReference, "blob_size_limit", "1MB",
|
||||||
|
"blob_size_limit must be at least"},
|
||||||
|
// The type check on a config that does not load yet.
|
||||||
|
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
// The type check on a config that cannot be read here.
|
||||||
|
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
||||||
|
"cannot unmarshal !!str `/x`"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key+" "+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(tt.config), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.reason) {
|
||||||
|
t.Errorf("config set %s %s gives error %v, want one saying %q",
|
||||||
|
tt.key, tt.value, err, tt.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != tt.config {
|
||||||
|
t.Errorf("config set %s changed the file to:\n%s",
|
||||||
|
tt.key, data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetRefusesOnlyItsOwnFailure checks that setting another key
|
||||||
|
// works on a config with a ${...} reference on compression_level, which
|
||||||
|
// loads because loading fills the reference in, on one whose
|
||||||
|
// compression_level already fails to load, and on one with a reference
|
||||||
|
// that cannot be filled in here.
|
||||||
|
func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configs := map[string]string{
|
||||||
|
"reference": configWithLevelReference(t),
|
||||||
|
"failing": configWithoutRecipients + "compression_level: fast\n",
|
||||||
|
"unset reference": configWithUnsetReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, configYAML := range configs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configYAML), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, "hostname", "myhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set hostname: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "hostname: myhost") {
|
||||||
|
t.Errorf("config set hostname wrote:\n%s", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
||||||
|
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
||||||
|
// config.Load returns each one unchanged.
|
||||||
|
func TestConfigSetStringLooksLikeNumber(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
field func(cfg *config.Config) string
|
||||||
|
}{
|
||||||
|
{"s3.access_key_id", "00112233",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.AccessKeyID }},
|
||||||
|
{"s3.secret_access_key", "12345678901234567890123456789012",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.SecretAccessKey }},
|
||||||
|
{"hostname", "007",
|
||||||
|
func(cfg *config.Config) string { return cfg.Hostname }},
|
||||||
|
{"s3.prefix", "1e5",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.Prefix }},
|
||||||
|
{"s3.bucket", "true",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.Bucket }},
|
||||||
|
{"s3.region", "FALSE",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.Region }},
|
||||||
|
{"snapshots.home.exclude.0", "1.10",
|
||||||
|
func(cfg *config.Config) string { return cfg.Snapshots["home"].Exclude[0] }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key+"="+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := loadAfterConfigSet(t, tt.key, tt.value)
|
||||||
|
|
||||||
|
got := tt.field(cfg)
|
||||||
|
if got != tt.value {
|
||||||
|
t.Errorf("%s = %q after config set %q", tt.key, got, tt.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetNonUTF8Path checks that config set still accepts a value that
|
||||||
|
// is not valid UTF-8, such as a path with a Latin-1 file name, and that
|
||||||
|
// config.Load returns it unchanged.
|
||||||
|
func TestConfigSetNonUTF8Path(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const dir = "/srv/caf\xe9"
|
||||||
|
|
||||||
|
cfg := loadAfterConfigSet(t, "snapshots.home.paths.0", dir)
|
||||||
|
|
||||||
|
got := cfg.Snapshots["home"].Paths[0]
|
||||||
|
if got != dir {
|
||||||
|
t.Errorf("snapshots.home.paths.0 = %q, want %q", got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetNumberStaysNumber checks that a number set for an integer
|
||||||
|
// setting is still read as a number, not as a quoted string.
|
||||||
|
func TestConfigSetNumberStaysNumber(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const level = 9
|
||||||
|
|
||||||
|
cfg := loadAfterConfigSet(t, "compression_level", strconv.Itoa(level))
|
||||||
|
|
||||||
|
if cfg.CompressionLevel != level {
|
||||||
|
t.Errorf("compression_level = %d, want %d", cfg.CompressionLevel, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAfterConfigSet writes the file `config init` writes, sets storage_url
|
||||||
|
// to a local directory so that the file passes validation, applies
|
||||||
|
// `config set key value` and returns what config.Load reads back.
|
||||||
|
func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set storage_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, key, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set %s: %v", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
const testYAML = `# top comment
|
||||||
|
compression_level: 3
|
||||||
|
age_recipients:
|
||||||
|
- age1aaa
|
||||||
|
s3:
|
||||||
|
bucket: oldbucket # inline comment
|
||||||
|
region: us-east-1
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
`
|
||||||
|
|
||||||
|
func parseTestYAML(t *testing.T) *yaml.Node {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var root yaml.Node
|
||||||
|
|
||||||
|
err := yaml.Unmarshal([]byte(testYAML), &root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing test yaml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLPathGet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := parseTestYAML(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"compression_level", "3", false},
|
||||||
|
{"s3.bucket", "oldbucket", false},
|
||||||
|
{"s3.region", "us-east-1", false},
|
||||||
|
{"age_recipients.0", "age1aaa", false},
|
||||||
|
{"age_recipients.5", "", true},
|
||||||
|
{"age_recipients.notanumber", "", true},
|
||||||
|
{"s3.nonexistent", "", true},
|
||||||
|
{"nonexistent", "", true},
|
||||||
|
{"compression_level.sub", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
node, err := yamlPathGet(root, splitPath(tt.path))
|
||||||
|
if tt.err {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for %q", tt.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.Value != tt.want {
|
||||||
|
t.Errorf("get %q = %q, want %q", tt.path, node.Value, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLPathSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := parseTestYAML(t)
|
||||||
|
|
||||||
|
// Overwrite existing nested value
|
||||||
|
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set s3.bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new nested key with intermediate map
|
||||||
|
err = yamlPathSet(root, splitPath("s3.endpoint"), "s3.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set s3.endpoint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = yamlPathSet(root, splitPath("newmap.newkey"), "val")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set newmap.newkey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite a sequence element and append a new one
|
||||||
|
err = yamlPathSet(root, splitPath("age_recipients.0"), "age1bbb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set age_recipients.0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = yamlPathSet(root, splitPath("age_recipients.1"), "age1ccc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append age_recipients.1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = yamlPathSet(root, splitPath("age_recipients.5"), "age1ddd")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected out-of-range append to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip and verify values + comment preservation
|
||||||
|
out, err := yaml.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(out)
|
||||||
|
|
||||||
|
wants := []string{
|
||||||
|
"newbucket", "s3.example.com", "newkey: val",
|
||||||
|
"# top comment", "# inline comment", "age1bbb", "age1ccc",
|
||||||
|
}
|
||||||
|
for _, want := range wants {
|
||||||
|
if !contains(text, want) {
|
||||||
|
t.Errorf("round-tripped YAML missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := yamlPathGet(root, splitPath("s3.bucket"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get after set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Value != "newbucket" {
|
||||||
|
t.Errorf("s3.bucket = %q after set, want newbucket", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetPreservesFormatting asserts the `config set` write path
|
||||||
|
// (marshalConfigYAML) round-trips a 2-space-indented file without reindenting
|
||||||
|
// it to yaml.Marshal's 4-space default, and keeps comments.
|
||||||
|
func TestConfigSetPreservesFormatting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := parseTestYAML(t)
|
||||||
|
|
||||||
|
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set s3.bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := marshalConfigYAML(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(out)
|
||||||
|
|
||||||
|
for _, want := range []string{"# top comment", "# inline comment"} {
|
||||||
|
if !contains(text, want) {
|
||||||
|
t.Errorf("round-tripped YAML dropped comment %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested map keys stay at 2-space indent; the bug reindented them to 4.
|
||||||
|
if !contains(text, "\n bucket: newbucket") {
|
||||||
|
t.Errorf("expected 2-space indent for s3.bucket, got:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contains(text, "\n bucket:") {
|
||||||
|
t.Errorf("s3.bucket reindented to 4 spaces:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequence items under a key also stay at 2 spaces.
|
||||||
|
if !contains(text, "\n - age1aaa") {
|
||||||
|
t.Errorf("expected 2-space indent for sequence item, got:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteConfigSetHidesSecret checks that setting a secret key prints
|
||||||
|
// only the key name, never the value, to the confirmation output.
|
||||||
|
func TestWriteConfigSetHidesSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const secret = "SUPERSECRETVALUE"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("version: 1\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||||
|
"s3.secret_access_key", secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeConfigSet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), secret) {
|
||||||
|
t.Errorf("output echoed the secret value: %q", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), "s3.secret_access_key") {
|
||||||
|
t.Errorf("output did not confirm the key name: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteConfigSetTightensMode checks that a pre-existing group- or
|
||||||
|
// world-readable config is tightened to owner-only after a set, since
|
||||||
|
// os.WriteFile leaves an existing file's mode untouched.
|
||||||
|
func TestWriteConfigSetTightensMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
|
||||||
|
// Seed a world-readable config; the loose mode is the condition under
|
||||||
|
// test, so gosec's G306 is expected here.
|
||||||
|
err := os.WriteFile(path, []byte("version: 1\n"), 0o644) //nolint:gosec // G306
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||||
|
"compression_level", "9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeConfigSet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("config mode = %04o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an
|
||||||
|
// argument works and that a config path with a space reaches it whole.
|
||||||
|
// The "editor" is cp with the replacement file as its argument, so the
|
||||||
|
// config ends up with the replacement's content only if both arrived.
|
||||||
|
func TestConfigEditEditorWithArgument(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
|
||||||
|
edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||||
|
t.Setenv("EDITOR", "cp '"+edited+"'")
|
||||||
|
|
||||||
|
mustExecute(t, newConfigEditCommand())
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "compression_level: 9\n" {
|
||||||
|
t.Errorf("config after edit = %q, want the edited content", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPath(s string) []string {
|
||||||
|
return strings.Split(s, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
return strings.Contains(haystack, needle)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDatabaseCommand creates the database command group
|
||||||
|
func NewDatabaseCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "database",
|
||||||
|
Short: "Manage the local state database",
|
||||||
|
Long: `Commands for managing the local SQLite state database.`,
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newDatabaseDeleteCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDatabaseDeleteCommand creates the database delete command.
|
||||||
|
// (Renamed from "purge"; the operation removes the SQLite file
|
||||||
|
// entirely, which is a delete, not a purge of content.)
|
||||||
|
func newDatabaseDeleteCommand() *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete",
|
||||||
|
Short: "Delete the local state database file",
|
||||||
|
Long: `Completely removes the local SQLite state database.
|
||||||
|
|
||||||
|
This will erase all local tracking of:
|
||||||
|
- File metadata and change detection state
|
||||||
|
- Chunk and blob mappings
|
||||||
|
- Local snapshot records
|
||||||
|
- The storage-binding record
|
||||||
|
|
||||||
|
The remote storage is NOT affected. After deletion, the next backup
|
||||||
|
will perform a full scan and re-deduplicate against existing remote
|
||||||
|
blobs, and the local index will re-bind to the currently configured
|
||||||
|
storage destination on that run.
|
||||||
|
|
||||||
|
Use --force to skip the confirmation prompt.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Resolve config path
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config to get database path
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := cfg.IndexPath
|
||||||
|
out := commandUI(cmd)
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
_, err = os.Stat(dbPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
out.Infof("Local state database does not exist: %s.", dbPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm unless --force. The prompt and its immediate result
|
||||||
|
// are an interactive exchange the operator must see, so they go
|
||||||
|
// straight to stdout rather than through the UI and --quiet does
|
||||||
|
// not silence them.
|
||||||
|
if !force {
|
||||||
|
w := cmd.OutOrStdout()
|
||||||
|
_, _ = fmt.Fprintf(w,
|
||||||
|
"This will delete the local state database at:\n %s\n\n", dbPath)
|
||||||
|
_, _ = fmt.Fprint(w, "Are you sure? Type 'yes' to confirm: ")
|
||||||
|
|
||||||
|
var confirm string
|
||||||
|
|
||||||
|
_, err = fmt.Scanln(&confirm)
|
||||||
|
if err != nil || confirm != "yes" {
|
||||||
|
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||||
|
|
||||||
|
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the database file
|
||||||
|
err = os.Remove(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete WAL and SHM files if they exist
|
||||||
|
walPath := dbPath + "-wal"
|
||||||
|
shmPath := dbPath + "-shm"
|
||||||
|
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
||||||
|
_ = os.Remove(shmPath)
|
||||||
|
|
||||||
|
out.Infof("Local state database deleted: %s.", dbPath)
|
||||||
|
log.Info("Local state database deleted", "path", dbPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+121
-4
@@ -1,13 +1,130 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CLIEntry is the main entry point for the CLI application
|
// shortCommitLen is the number of git commit hash characters shown in
|
||||||
func CLIEntry() {
|
// the startup banner.
|
||||||
|
const shortCommitLen = 12
|
||||||
|
|
||||||
|
// exitCodeInterrupted is the exit status of a command that SIGINT or
|
||||||
|
// SIGTERM stopped. It is 128 plus SIGINT's number, 2, which is what a
|
||||||
|
// shell reports for a command stopped by Ctrl-C.
|
||||||
|
const exitCodeInterrupted = 130
|
||||||
|
|
||||||
|
// Entry is the main entry point for the CLI application.
|
||||||
|
// It prints the startup banner to stderr (unless a banner-suppressing
|
||||||
|
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
||||||
|
// root cobra command, and routes any returned error through the
|
||||||
|
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
|
||||||
|
// The banner goes to stderr because stdout carries only the output the
|
||||||
|
// user asked for, such as a completion script or a `config get` value.
|
||||||
|
//
|
||||||
|
// It returns the process exit code (0 on success, 130 when interrupted,
|
||||||
|
// 1 on any other error) rather than calling os.Exit, so that main's
|
||||||
|
// deferred profile writers run before the process ends. See run in
|
||||||
|
// cmd/vaultik/main.go.
|
||||||
|
func Entry() int {
|
||||||
|
emitStartupBanner(os.Args[1:], os.Stderr)
|
||||||
|
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
rootCmd.SilenceErrors = true
|
||||||
os.Exit(1)
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
// An operation that ran inside the fx app has already reported
|
||||||
|
// its own failure (`snapshot verify --json` puts it in the
|
||||||
|
// document instead); errReported says so. Printing it again
|
||||||
|
// here would double the error line.
|
||||||
|
// Every other error — bad arguments, a config that would not
|
||||||
|
// load, an interrupt — reaches Entry unreported, so it is shown
|
||||||
|
// here.
|
||||||
|
if !errors.Is(err, errReported) {
|
||||||
|
ReportErrorf("%s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, errInterrupted) {
|
||||||
|
return exitCodeInterrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitStartupBanner writes the startup banner to w unless args (the
|
||||||
|
// argument vector with the program name already stripped) contains a
|
||||||
|
// flag that suppresses it. Split out of Entry so that the decision is
|
||||||
|
// reachable from a test without running the whole CLI.
|
||||||
|
func emitStartupBanner(args []string, w io.Writer) {
|
||||||
|
if bannerSuppressedInArgs(args) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
short := globals.Commit
|
||||||
|
if len(short) > shortCommitLen {
|
||||||
|
short = short[:shortCommitLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStartupBanner(ui.New(w), time.Now().UTC(), short)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportErrorf emits a user-facing error to stderr in the standard
|
||||||
|
// 🛑 ERROR: format. Use it from goroutine error paths (where returning
|
||||||
|
// an error to cobra isn't an option) and anywhere else a CLI command
|
||||||
|
// must surface a failure outside the normal RunE return path.
|
||||||
|
func ReportErrorf(format string, args ...any) {
|
||||||
|
ui.New(os.Stderr).Errorf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bannerSuppressedInArgs reports whether any of args is a flag that
|
||||||
|
// should suppress the startup banner (--quiet/-q/--cron/--json). Stops
|
||||||
|
// at the "--" argument terminator. Recognizes both long forms and short
|
||||||
|
// -q, including combined short flags like "-qv".
|
||||||
|
//
|
||||||
|
// This scans the raw argument vector because the banner is printed
|
||||||
|
// before cobra parses anything — deliberately, so that it still appears
|
||||||
|
// when cobra rejects the arguments and on --help. The consequence is
|
||||||
|
// that a flag is matched wherever it occurs in the vector, including
|
||||||
|
// positions where the command it belongs to would not accept it.
|
||||||
|
// --json is a subcommand flag rather than a persistent one, but so is
|
||||||
|
// --cron (it exists only on `snapshot create`), so this adds no new
|
||||||
|
// class of imprecision. The only cost of a false positive is a missing
|
||||||
|
// decorative banner.
|
||||||
|
func bannerSuppressedInArgs(args []string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "--" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch a {
|
||||||
|
case "--quiet", "-q", "--cron", "--json":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(a, "--quiet=") ||
|
||||||
|
strings.HasPrefix(a, "--cron=") ||
|
||||||
|
strings.HasPrefix(a, "--json=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Combined short flags like -qv or -vq.
|
||||||
|
if len(a) > 1 && a[0] == '-' && a[1] != '-' {
|
||||||
|
for _, c := range a[1:] {
|
||||||
|
if c == 'q' {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package cli //nolint:testpackage // needs access to unexported emitStartupBanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command words and flags used to build argument vectors below. They are
|
||||||
|
// constants rather than repeated literals so that a rename shows up as a
|
||||||
|
// compile error in one place.
|
||||||
|
const (
|
||||||
|
cmdSnapshot = "snapshot"
|
||||||
|
cmdList = "list"
|
||||||
|
cmdCreate = "create"
|
||||||
|
cmdVerify = "verify"
|
||||||
|
cmdRemove = "remove"
|
||||||
|
cmdPrune = "prune"
|
||||||
|
cmdRemote = "remote"
|
||||||
|
cmdInfo = "info"
|
||||||
|
|
||||||
|
flagJSON = "--json"
|
||||||
|
flagQuiet = "--quiet"
|
||||||
|
flagConfig = "--config"
|
||||||
|
|
||||||
|
// programName is argv[0] as the real process receives it. Entry
|
||||||
|
// strips it before scanning, so it has to be present.
|
||||||
|
programName = "vaultik"
|
||||||
|
|
||||||
|
// someSnapshotID only fills the positional argument; no test needs
|
||||||
|
// the snapshot to exist.
|
||||||
|
someSnapshotID = "host_2026-01-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonArgumentVectors are the argument vectors of every --json
|
||||||
|
// invocation the CLI accepts, with the program name stripped exactly as
|
||||||
|
// Entry strips it. Each one must suppress the banner.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // read-only test fixture shared by two tests
|
||||||
|
var jsonArgumentVectors = map[string][]string{
|
||||||
|
"snapshot list": {cmdSnapshot, cmdList, flagJSON},
|
||||||
|
"snapshot verify": {cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
|
||||||
|
"snapshot remove": {cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
|
||||||
|
"prune": {cmdPrune, flagJSON},
|
||||||
|
"remote info": {cmdRemote, cmdInfo, flagJSON},
|
||||||
|
|
||||||
|
// --json before the subcommand, and with an explicit value: the
|
||||||
|
// scan is positional, so both forms have to be recognized.
|
||||||
|
"json first": {flagJSON, cmdSnapshot, cmdList},
|
||||||
|
"json with value": {cmdSnapshot, cmdList, flagJSON + "=true"},
|
||||||
|
|
||||||
|
// A --json invocation that also carries a flag with a value, so the
|
||||||
|
// scan cannot be fooled by an argument that consumes the next one.
|
||||||
|
"json with config": {
|
||||||
|
flagConfig, "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJSONInvocationSuppressesBanner checks that every --json
|
||||||
|
// invocation suppresses the startup banner, as the README says --json
|
||||||
|
// does along with --quiet and --cron. The scan is over the raw argument
|
||||||
|
// vector, so each position and spelling of --json is listed.
|
||||||
|
func TestJSONInvocationSuppressesBanner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, argv := range jsonArgumentVectors {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var banner bytes.Buffer
|
||||||
|
|
||||||
|
emitStartupBanner(argv, &banner)
|
||||||
|
|
||||||
|
assert.Empty(t, banner.String(),
|
||||||
|
"--json suppresses the banner")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBannerStillPrintedWithoutSuppressingFlag pins the other half of
|
||||||
|
// the contract. Without it, deleting the banner outright would satisfy
|
||||||
|
// the test above, and the banner is wanted on interactive invocations.
|
||||||
|
func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, argv := range map[string][]string{
|
||||||
|
"no flags": {cmdSnapshot, cmdList},
|
||||||
|
"verbose": {cmdSnapshot, cmdList, "--verbose"},
|
||||||
|
"after the terminator": {
|
||||||
|
cmdSnapshot, "restore", "--", flagJSON,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var banner bytes.Buffer
|
||||||
|
|
||||||
|
emitStartupBanner(argv, &banner)
|
||||||
|
|
||||||
|
assert.Contains(t, banner.String(), "starting up at",
|
||||||
|
"the banner belongs on invocations that did not opt out")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBannerSuppressedInArgs covers the suppression scan directly,
|
||||||
|
// including the flags that suppressed the banner before --json joined
|
||||||
|
// them, so that adding --json cannot regress them.
|
||||||
|
func TestBannerSuppressedInArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, testCase := range map[string]struct {
|
||||||
|
args []string
|
||||||
|
suppressed bool
|
||||||
|
}{
|
||||||
|
"quiet long": {[]string{cmdSnapshot, cmdCreate, flagQuiet}, true},
|
||||||
|
"quiet short": {[]string{cmdSnapshot, cmdCreate, "-q"}, true},
|
||||||
|
"quiet combined": {[]string{cmdSnapshot, cmdCreate, "-qv"}, true},
|
||||||
|
"cron": {[]string{cmdSnapshot, cmdCreate, "--cron"}, true},
|
||||||
|
"json": {[]string{cmdSnapshot, cmdList, flagJSON}, true},
|
||||||
|
"nothing": {[]string{cmdSnapshot, cmdList}, false},
|
||||||
|
"empty": {nil, false},
|
||||||
|
"json after dashes": {
|
||||||
|
[]string{cmdSnapshot, cmdList, "--", flagJSON}, false,
|
||||||
|
},
|
||||||
|
"quiet after dashes": {
|
||||||
|
[]string{cmdSnapshot, cmdCreate, "--", "-q"}, false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.suppressed,
|
||||||
|
bannerSuppressedInArgs(testCase.args))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hermeticConfig is a complete, valid config that needs no network and
|
||||||
|
// no credentials: file:// storage is exempt from the S3 credential
|
||||||
|
// checks. A test that lists the destination must create its directory
|
||||||
|
// first, because listing a directory that does not exist is an error.
|
||||||
|
// Chunk, blob and compression settings are filled in by config.Load.
|
||||||
|
const hermeticConfig = `age_recipients:
|
||||||
|
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
||||||
|
snapshots:
|
||||||
|
test:
|
||||||
|
paths:
|
||||||
|
- %s
|
||||||
|
storage_url: file://%s
|
||||||
|
index_path: %s
|
||||||
|
hostname: test-host
|
||||||
|
`
|
||||||
|
|
||||||
|
// TestEntryJSONStdoutIsExactlyOneDocument runs the real thing: Entry,
|
||||||
|
// with a real argument vector, over the process's real stdout file
|
||||||
|
// descriptor, all the way through cobra and the fx graph to the
|
||||||
|
// document. It is the assertion the issue asks for — `vaultik snapshot
|
||||||
|
// list --json | jq .` with no other flags — with the pipe replaced by a
|
||||||
|
// decoder.
|
||||||
|
//
|
||||||
|
// `snapshot list` is the command chosen because it is the only --json
|
||||||
|
// command that reaches its document without a populated destination
|
||||||
|
// store: it reads the local index, streams `metadata/` (empty here),
|
||||||
|
// and treats an empty destination directory as an empty list rather
|
||||||
|
// than a failure.
|
||||||
|
//
|
||||||
|
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
||||||
|
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|
||||||
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
|
filepath.Join(dir, "source"),
|
||||||
|
storeDir,
|
||||||
|
filepath.Join(dir, "index.sqlite"))
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
|
// package init; point it at the temp dir so the test neither
|
||||||
|
// touches nor collides with the real one.
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||||
|
xdg.Reload()
|
||||||
|
t.Cleanup(xdg.Reload)
|
||||||
|
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = []string{
|
||||||
|
programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
||||||
|
|
||||||
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
var snapshots []any
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &snapshots))
|
||||||
|
assert.Empty(t, snapshots,
|
||||||
|
"a destination store with no snapshots lists none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureProcessStdout redirects the process's own stdout to a pipe for
|
||||||
|
// the duration of fn and returns what was written to it. The redirection
|
||||||
|
// has to be at the file-descriptor level rather than through an injected
|
||||||
|
// writer, because the commands Entry runs reach os.Stdout directly.
|
||||||
|
//
|
||||||
|
// Not parallel-safe: os.Stdout is process-global.
|
||||||
|
func captureProcessStdout(t *testing.T, fn func()) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, writer, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
previous := os.Stdout
|
||||||
|
os.Stdout = writer
|
||||||
|
|
||||||
|
captured := make(chan string, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
_, _ = io.Copy(&buf, reader)
|
||||||
|
captured <- buf.String()
|
||||||
|
}()
|
||||||
|
|
||||||
|
fn()
|
||||||
|
|
||||||
|
os.Stdout = previous
|
||||||
|
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
out := <-captured
|
||||||
|
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireExactlyOneJSONDocument fails unless stdout decodes as a single
|
||||||
|
// JSON value with nothing before or after it — the property that makes
|
||||||
|
// `| jq` work.
|
||||||
|
func requireExactlyOneJSONDocument(t *testing.T, stdout string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(stdout))
|
||||||
|
|
||||||
|
var document any
|
||||||
|
|
||||||
|
err := decoder.Decode(&document)
|
||||||
|
require.NoError(t, err,
|
||||||
|
"stdout must parse as JSON, got:\n%s", stdout)
|
||||||
|
|
||||||
|
_, err = decoder.Token()
|
||||||
|
require.ErrorIs(t, err, io.EOF,
|
||||||
|
"stdout must hold exactly one JSON document, got:\n%s", stdout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the argument constants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stalledStoreConfig is hermeticConfig with an s3:// destination store
|
||||||
|
// in place of the file:// one. The server behind it accepts any
|
||||||
|
// credentials.
|
||||||
|
const stalledStoreConfig = `age_recipients:
|
||||||
|
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
||||||
|
snapshots:
|
||||||
|
test:
|
||||||
|
paths:
|
||||||
|
- %s
|
||||||
|
storage_url: s3://bucket?endpoint=%s&ssl=false
|
||||||
|
s3:
|
||||||
|
access_key_id: key
|
||||||
|
secret_access_key: secret
|
||||||
|
index_path: %s
|
||||||
|
hostname: test-host
|
||||||
|
`
|
||||||
|
|
||||||
|
// interruptRepeat is how often interruptOnFirstRequest sends SIGINT.
|
||||||
|
const interruptRepeat = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// TestEntryInterruptedRun sends SIGINT to the test process while a
|
||||||
|
// command waits on the destination store, and checks that Entry returns
|
||||||
|
// 130 and prints one line on stderr saying the run was interrupted. The
|
||||||
|
// store is a local HTTP server that holds every request open, so the
|
||||||
|
// command is always mid-operation when the signal arrives. The two
|
||||||
|
// cases cover --cron and --json, which silence other output.
|
||||||
|
//
|
||||||
|
// Not parallel: it signals the process and replaces os.Args, os.Stdout,
|
||||||
|
// os.Stderr and the xdg globals.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // signals the process and replaces process globals
|
||||||
|
func TestEntryInterruptedRun(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "snapshot create --cron",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, "--cron"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snapshot verify --json",
|
||||||
|
args: []string{cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
endpoint, requestArrived := startStalledStore(t)
|
||||||
|
configPath := writeStalledStoreConfig(t, endpoint)
|
||||||
|
interruptOnFirstRequest(t, requestArrived)
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 130, code)
|
||||||
|
assert.Equal(t, 1,
|
||||||
|
strings.Count(stderr, errInterrupted.Error()), stderr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// interruptOnFirstRequest sends SIGINT to the test process every
|
||||||
|
// interruptRepeat, from the first request to the destination store until
|
||||||
|
// the test ends. One signal is not enough: the command can reach the
|
||||||
|
// store before fx has started catching signals. The test catches SIGINT
|
||||||
|
// too, so that a signal fx is not catching does not kill the test
|
||||||
|
// binary.
|
||||||
|
func interruptOnFirstRequest(t *testing.T, requestArrived <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
self, err := os.FindProcess(os.Getpid())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
caught := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(caught, os.Interrupt)
|
||||||
|
|
||||||
|
testEnded := make(chan struct{})
|
||||||
|
senderDone := make(chan struct{})
|
||||||
|
|
||||||
|
// Stop catching SIGINT only after the sender has returned. The sender
|
||||||
|
// waits for each SIGINT it sends to arrive on caught; one still on
|
||||||
|
// its way after signal.Stop would kill the test binary.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(testEnded)
|
||||||
|
<-senderDone
|
||||||
|
signal.Stop(caught)
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(senderDone)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-requestArrived:
|
||||||
|
case <-testEnded:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interruptRepeat)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Empty caught, so that the receive below waits for this
|
||||||
|
// SIGINT rather than an earlier one.
|
||||||
|
select {
|
||||||
|
case <-caught:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
sendErr := self.Signal(os.Interrupt)
|
||||||
|
if sendErr != nil {
|
||||||
|
t.Errorf("sending SIGINT: %v", sendErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
<-caught
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-testEnded:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startStalledStore starts an HTTP server that never answers: each
|
||||||
|
// request is held until the client gives up on it or the test ends.
|
||||||
|
// It returns the server's host:port and a channel that receives a value
|
||||||
|
// when the first request arrives.
|
||||||
|
func startStalledStore(t *testing.T) (string, <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
requestArrived := make(chan struct{}, 1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case requestArrived <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-release:
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Cleanups run last-registered first, so release lets any held
|
||||||
|
// request return before Close waits for it.
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
|
return server.Listener.Addr().String(), requestArrived
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStalledStoreConfig writes a config whose destination store is the
|
||||||
|
// server at endpoint and whose snapshot source holds one small file, so
|
||||||
|
// that `snapshot create` has a blob to upload. Returns the config path.
|
||||||
|
func writeStalledStoreConfig(t *testing.T, endpoint string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
sourceDir := filepath.Join(dir, "source")
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "file.txt"),
|
||||||
|
[]byte("contents"), 0o600))
|
||||||
|
|
||||||
|
contents := fmt.Sprintf(stalledStoreConfig,
|
||||||
|
sourceDir, endpoint, filepath.Join(dir, "index.sqlite"))
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
|
// package init; point it at the temp dir so the test neither
|
||||||
|
// touches nor collides with the real one.
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||||
|
xdg.Reload()
|
||||||
|
t.Cleanup(xdg.Reload)
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package cli //nolint:testpackage // shares the prune fixtures and capture helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staleRecordLogMessage is the local-cleanup audit line CleanupLocalSnapshots
|
||||||
|
// logs for each stale record. It is exactly the signal issue #112 says a
|
||||||
|
// machine consumer lost under --json: gated off stdout, and pinned below
|
||||||
|
// the log level on stderr because --json used to force Quiet.
|
||||||
|
const staleRecordLogMessage = "Removing stale local snapshot record"
|
||||||
|
|
||||||
|
// TestEntryPruneJSONStderrHonoursVerbosity is the end-to-end regression
|
||||||
|
// guard for issue #112. Under --json the log level must still follow
|
||||||
|
// --verbose/--debug rather than being pinned to WARN, so the
|
||||||
|
// local-cleanup records reach stderr under --verbose while stdout stays
|
||||||
|
// exactly one JSON document; without --verbose they stay below the
|
||||||
|
// level, as they do without --json.
|
||||||
|
//
|
||||||
|
// Both halves are asserted together on the same run, because the fix has
|
||||||
|
// to keep the document clean (issue #108) while freeing stderr.
|
||||||
|
//
|
||||||
|
// Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg
|
||||||
|
// globals.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryPruneJSONStderrHonoursVerbosity(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
verbose bool
|
||||||
|
wantOnStderr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "verbose json surfaces the cleanup record on stderr",
|
||||||
|
verbose: true,
|
||||||
|
wantOnStderr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json alone keeps the cleanup record below the level",
|
||||||
|
verbose: false,
|
||||||
|
wantOnStderr: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, true)
|
||||||
|
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
||||||
|
}
|
||||||
|
if testCase.verbose {
|
||||||
|
args = append(args, "--verbose")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Args = args
|
||||||
|
|
||||||
|
stdout, stderr := captureProcessStdoutAndStderr(t,
|
||||||
|
func() { _ = Entry() })
|
||||||
|
|
||||||
|
// The document stays clean in both cases: freeing stderr must
|
||||||
|
// not regress issue #108.
|
||||||
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
if testCase.wantOnStderr {
|
||||||
|
assert.Contains(t, stderr, staleRecordLogMessage,
|
||||||
|
"--verbose --json must emit the cleanup record on stderr")
|
||||||
|
assert.Contains(t, stderr, stalePruneSnapshotID,
|
||||||
|
"the record must name the snapshot it removed")
|
||||||
|
} else {
|
||||||
|
assert.NotContains(t, stderr, staleRecordLogMessage,
|
||||||
|
"without --verbose the record stays below the log level")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureProcessStdoutAndStderr redirects both of the process's own
|
||||||
|
// standard streams to pipes for the duration of fn and returns what was
|
||||||
|
// written to each. The redirection is at the file-descriptor level
|
||||||
|
// because the logger binds os.Stderr when it initializes inside fn, and
|
||||||
|
// the JSON document reaches os.Stdout independently; the point is to see
|
||||||
|
// where each actually lands.
|
||||||
|
//
|
||||||
|
// Not parallel-safe: os.Stdout and os.Stderr are process-global.
|
||||||
|
func captureProcessStdoutAndStderr(t *testing.T, fn func()) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
outReader, outWriter, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
errReader, errWriter, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
previousOut, previousErr := os.Stdout, os.Stderr
|
||||||
|
os.Stdout, os.Stderr = outWriter, errWriter
|
||||||
|
|
||||||
|
capturedOut := drain(outReader)
|
||||||
|
capturedErr := drain(errReader)
|
||||||
|
|
||||||
|
fn()
|
||||||
|
|
||||||
|
os.Stdout, os.Stderr = previousOut, previousErr
|
||||||
|
|
||||||
|
require.NoError(t, outWriter.Close())
|
||||||
|
require.NoError(t, errWriter.Close())
|
||||||
|
|
||||||
|
out, errOut := <-capturedOut, <-capturedErr
|
||||||
|
|
||||||
|
require.NoError(t, outReader.Close())
|
||||||
|
require.NoError(t, errReader.Close())
|
||||||
|
|
||||||
|
return out, errOut
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain copies a reader to a string on a goroutine and delivers the
|
||||||
|
// result once the writer end is closed.
|
||||||
|
func drain(reader io.Reader) <-chan string {
|
||||||
|
captured := make(chan string, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
_, _ = io.Copy(&buf, reader)
|
||||||
|
captured <- buf.String()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return captured
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pruneJSONDocument is the shape `prune --json` writes: the
|
||||||
|
// PruneBlobsResult document, and nothing else.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // snake_case is the established JSON output format
|
||||||
|
type pruneJSONDocument struct {
|
||||||
|
BlobsFound int `json:"blobs_found"`
|
||||||
|
BlobsDeleted int `json:"blobs_deleted"`
|
||||||
|
BytesFreed int64 `json:"bytes_freed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// stalePruneSnapshotID is seeded into the local index with no manifest
|
||||||
|
// on the destination store, which is exactly what makes it stale.
|
||||||
|
const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z"
|
||||||
|
|
||||||
|
// TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end
|
||||||
|
// regression guard for issue #108: `vaultik prune --json | jq .` must
|
||||||
|
// work with no other flags.
|
||||||
|
//
|
||||||
|
// It runs Entry over the process's real stdout descriptor, through
|
||||||
|
// cobra and the fx graph, against a hermetic file:// destination store
|
||||||
|
// — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses
|
||||||
|
// for `snapshot list`, with the pipe to jq replaced by a decoder.
|
||||||
|
//
|
||||||
|
// Both branches of the local-snapshot reconciliation are exercised
|
||||||
|
// because the three stdout writes that broke this covered all of them:
|
||||||
|
// one line per stale record and a summary when there were any, and a
|
||||||
|
// "No stale local snapshots found." line when there were none. No input
|
||||||
|
// avoided the contamination, so no single branch demonstrates the fix.
|
||||||
|
//
|
||||||
|
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals
|
||||||
|
func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
seedStale bool
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no stale local records",
|
||||||
|
seedStale: false,
|
||||||
|
description: "the empty-index branch used to print a 'No stale' line",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stale local records present",
|
||||||
|
seedStale: true,
|
||||||
|
description: "the removal branch used to print a line per record " +
|
||||||
|
"plus a summary",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, testCase.seedStale)
|
||||||
|
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = []string{
|
||||||
|
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
||||||
|
|
||||||
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
var document pruneJSONDocument
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &document),
|
||||||
|
testCase.description)
|
||||||
|
|
||||||
|
// A destination store with no blobs has none to prune. The
|
||||||
|
// assertion that matters is the one above; this one keeps the
|
||||||
|
// test honest about which document it decoded.
|
||||||
|
assert.Equal(t, 0, document.BlobsFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
|
// index database up front with one snapshot record that has no
|
||||||
|
// counterpart on the destination store, and gives the destination the
|
||||||
|
// marker a backup writes. Returns the config path.
|
||||||
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|
||||||
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
|
filepath.Join(dir, "source"),
|
||||||
|
storeDir,
|
||||||
|
indexPath)
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// prune fails on a destination directory that does not exist, so the
|
||||||
|
// empty store is created here rather than left to a first backup.
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
|
// package init; point it at the temp dir so the test neither
|
||||||
|
// touches nor collides with the real one.
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||||
|
xdg.Reload()
|
||||||
|
t.Cleanup(xdg.Reload)
|
||||||
|
|
||||||
|
if seedStale {
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, prune fails on a
|
||||||
|
// destination that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStaleSnapshotRecord creates the index database at path and
|
||||||
|
// inserts one completed snapshot into it. Nothing is written to the
|
||||||
|
// destination store, so `prune` finds the record stale and removes it —
|
||||||
|
// the branch that printed a line per record.
|
||||||
|
func seedStaleSnapshotRecord(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
completedAt := startedAt.Add(time.Minute)
|
||||||
|
|
||||||
|
snap := &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(stalePruneSnapshotID),
|
||||||
|
Hostname: "test-host",
|
||||||
|
VaultikVersion: "test",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return repos.Snapshots.Create(ctx, tx, snap)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flagCron is used to build the argument vectors below.
|
||||||
|
const flagCron = "--cron"
|
||||||
|
|
||||||
|
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
||||||
|
// destination store at storeDir, dated long before any prune grace period,
|
||||||
|
// so that a prune deletes it. Returns its path.
|
||||||
|
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash := strings.Repeat("b", 64)
|
||||||
|
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
||||||
|
|
||||||
|
longAgo := time.Unix(0, 0)
|
||||||
|
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
|
||||||
|
// `snapshot create --prune` under --cron or --quiet prints nothing on
|
||||||
|
// stdout or stderr, whether or not the prune finds a blob to delete.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
plantBlob bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cron, no blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron, blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron with keep-newer-than",
|
||||||
|
args: []string{
|
||||||
|
cmdSnapshot, cmdCreate, flagCron, flagPrune,
|
||||||
|
flagKeepNewerThan, "4w",
|
||||||
|
},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quiet",
|
||||||
|
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.plantBlob {
|
||||||
|
blob = plantUnreferencedBlob(t, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.plantBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
|
||||||
|
// under --quiet prints nothing on stdout or stderr, both when there is
|
||||||
|
// nothing to remove and when it removes a stale local snapshot record
|
||||||
|
// and an unreferenced blob.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryPruneQuietIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
staleRecordAndBlob bool
|
||||||
|
}{
|
||||||
|
{name: "nothing to remove"},
|
||||||
|
{name: "stale record and blob to remove", staleRecordAndBlob: true},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
// writeHermeticPruneConfig puts the store beside the config.
|
||||||
|
blob = plantUnreferencedBlob(t,
|
||||||
|
filepath.Join(filepath.Dir(configPath), "store"))
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags of `snapshot create` used to build argument vectors below.
|
||||||
|
const (
|
||||||
|
flagPrune = "--prune"
|
||||||
|
flagKeepNewerThan = "--keep-newer-than"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup checks that a
|
||||||
|
// --keep-newer-than the post-backup prune could not use stops `snapshot
|
||||||
|
// create` before the backup: the command fails and the destination
|
||||||
|
// store stays empty. It used to be read only by the prune, after the
|
||||||
|
// snapshot was uploaded, and without --prune it was ignored.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "decimal in a day count",
|
||||||
|
args: []string{flagPrune, flagKeepNewerThan, "1.5d"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown unit",
|
||||||
|
args: []string{flagPrune, flagKeepNewerThan, "4x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without --prune",
|
||||||
|
args: []string{flagKeepNewerThan, "4w"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, append(
|
||||||
|
[]string{flagConfig, configPath, cmdSnapshot, cmdCreate},
|
||||||
|
testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
|
||||||
|
stored, err := os.ReadDir(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stored, "nothing may be uploaded")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
|
||||||
|
// post-backup prune fail after a good backup, by planting a manifest
|
||||||
|
// that cannot be decoded, and checks that the failure is reported as the
|
||||||
|
// prune's and not as a failed snapshot creation.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
// A remote key is 64 lowercase hex characters. The prune reads every
|
||||||
|
// manifest on the store and fails on one it cannot decode.
|
||||||
|
plantedDir := filepath.Join(storeDir, "metadata", strings.Repeat("a", 64))
|
||||||
|
require.NoError(t, os.MkdirAll(plantedDir, 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(plantedDir, "manifest.json.zst"),
|
||||||
|
[]byte("not a valid manifest"), 0o600))
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2,
|
||||||
|
"the new snapshot is on the store beside the planted one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
|
||||||
|
// empties the source directory, as a volume that is not mounted leaves
|
||||||
|
// its mount point, and runs `snapshot create --prune`. The command exits
|
||||||
|
// non-zero and reports the backup as completed, not as a failed snapshot
|
||||||
|
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
// writeBackupConfig puts the source directory beside the config file.
|
||||||
|
require.NoError(t, os.Remove(
|
||||||
|
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 source path(s) were empty")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
|
||||||
|
// up, records in the local index that this backup found the source
|
||||||
|
// directory as a mount point, and runs `snapshot create --prune`, which
|
||||||
|
// does not find it as one. The command exits non-zero, reports the
|
||||||
|
// backup as completed, not as a failed snapshot creation, and keeps the
|
||||||
|
// older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
// writeBackupConfig puts the source directory and the local index
|
||||||
|
// beside the config file.
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
|
||||||
|
backedUp.ID.String(), filepath.Join(dir, "source")))
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
|
||||||
|
"an earlier snapshot recorded were not found")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBackupConfig builds a hermetic config with one small file in its
|
||||||
|
// source directory and an empty destination store directory. Returns the
|
||||||
|
// config path and the store directory.
|
||||||
|
func writeBackupConfig(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
// writeHermeticPruneConfig puts the source and store directories
|
||||||
|
// beside the config file.
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
sourceDir := filepath.Join(dir, "source")
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
|
||||||
|
|
||||||
|
return configPath, filepath.Join(dir, "store")
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
||||||
|
// under --skip-errors, a source directory that does not exist, which the
|
||||||
|
// walk skips. The command exits non-zero and reports the backup as
|
||||||
|
// completed, not as a failed snapshot creation. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
||||||
|
// writeBackupConfig would create the source directory.
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, stdout, "Finished successfully.")
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 file(s) could not be read")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Words and flags of `snapshot purge` used to build argument vectors
|
||||||
|
// below.
|
||||||
|
const (
|
||||||
|
cmdPurge = "purge"
|
||||||
|
flagOlderThan = "--older-than"
|
||||||
|
flagForce = "--force"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
||||||
|
// the local index refuse to delete an old snapshot and checks that
|
||||||
|
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
||||||
|
// the destination store. It used to log the error, delete the metadata
|
||||||
|
// and exit 0.
|
||||||
|
//
|
||||||
|
// `snapshot create --prune` runs the same purge, but cannot be driven
|
||||||
|
// this way: the refusal also breaks the backup's metadata export, which
|
||||||
|
// deletes the other snapshots from its copy of the local index.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
// seedStaleSnapshotRecord writes an old snapshot to the local index
|
||||||
|
// only; the manifest planted here puts it on the destination store
|
||||||
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, purge fails on a
|
||||||
|
// destination that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
||||||
|
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "simulated index error",
|
||||||
|
"the purge must fail at the local-index delete")
|
||||||
|
assert.FileExists(t, manifestPath,
|
||||||
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package cli //nolint:testpackage // shares programName and the capture helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntryReturnsStatusCode pins the contract main() relies on for
|
||||||
|
// issue #75: Entry reports success or failure through its return value
|
||||||
|
// and never calls os.Exit. An os.Exit from inside Entry would skip
|
||||||
|
// main's deferred profile writers and truncate the profile of a failing
|
||||||
|
// command. main turns this code into os.Exit only after those defers
|
||||||
|
// run, so a failing command must come back with a non-zero code rather
|
||||||
|
// than ending the process here.
|
||||||
|
//
|
||||||
|
// Stdout and stderr are captured only to keep the banner and command
|
||||||
|
// output off the test log; the assertion is on the returned code.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args and rootFlags
|
||||||
|
func TestEntryReturnsStatusCode(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// version is self-contained: it needs no config and no
|
||||||
|
// destination store, so it exercises the success path.
|
||||||
|
name: "successful command returns zero",
|
||||||
|
args: []string{programName, "version"},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown command returns one",
|
||||||
|
args: []string{programName, "no-such-command"},
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = testCase.args
|
||||||
|
|
||||||
|
var code int
|
||||||
|
|
||||||
|
_, _ = captureProcessStdoutAndStderr(t, func() { code = Entry() })
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.want, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntryCompletionStdoutIsTheScript runs `vaultik completion bash`,
|
||||||
|
// whose stdout the README tells the user to source. The script has to
|
||||||
|
// start on the first line.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||||
|
func TestEntryCompletionStdoutIsTheScript(t *testing.T) {
|
||||||
|
code, stdout, _ := runEntry(t, "completion", "bash")
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
firstLine, _, _ := strings.Cut(stdout, "\n")
|
||||||
|
assert.True(t, strings.HasPrefix(firstLine, "# bash completion"),
|
||||||
|
"the first line of stdout must be the script's, got %q", firstLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryConfigGetStdoutIsTheValue runs `vaultik config get`, whose
|
||||||
|
// stdout a script reads as the value and nothing else.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||||
|
func TestEntryConfigGetStdoutIsTheValue(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
require.NoError(t, os.WriteFile(configPath,
|
||||||
|
[]byte("hostname: test-host\n"), configFileMode))
|
||||||
|
|
||||||
|
code, stdout, _ := runEntry(t,
|
||||||
|
flagConfig, configPath, "config", "get", "hostname")
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
assert.Equal(t, "test-host\n", stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryJSONFailureIsReportedOnStderr runs each --json command that
|
||||||
|
// writes no document when it fails, against a destination it cannot
|
||||||
|
// use. The error must reach stderr, and stdout must stay empty.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryJSONFailureIsReportedOnStderr(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantOnStderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remote info",
|
||||||
|
args: []string{cmdRemote, cmdInfo, flagJSON},
|
||||||
|
wantOnStderr: "Failed to get remote info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prune",
|
||||||
|
args: []string{cmdPrune, flagJSON},
|
||||||
|
wantOnStderr: "Prune failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snapshot remove",
|
||||||
|
args: []string{cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
|
||||||
|
wantOnStderr: "Failed to remove snapshot",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeUnusableDestinationConfig(t)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Empty(t, stdout,
|
||||||
|
"a failed --json command has no document to write")
|
||||||
|
assert.Contains(t, stderr, testCase.wantOnStderr,
|
||||||
|
"the failure must be reported on stderr")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotRemoveJSONWarningIsOnStderr runs `snapshot remove
|
||||||
|
// --json` on a snapshot in the local index, against a destination
|
||||||
|
// directory that does not exist. The command removes the snapshot from
|
||||||
|
// the local index and still exits 0. Its stdout must hold the document
|
||||||
|
// alone, with the warning about the destination store on stderr: the
|
||||||
|
// command to run again once it is reachable, and the snapshot's ID in
|
||||||
|
// the record's snapshot_id field.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) {
|
||||||
|
configPath, indexPath := writeMissingDestinationConfig(t)
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdRemove, stalePruneSnapshotID, flagJSON)
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
// stderr is a pipe here, so the logger writes one JSON record a line.
|
||||||
|
var warning map[string]any
|
||||||
|
|
||||||
|
for line := range strings.Lines(stderr) {
|
||||||
|
if strings.Contains(line,
|
||||||
|
"Could not remove snapshot metadata from remote storage") {
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(line), &warning))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, warning, "the warning must reach stderr")
|
||||||
|
assert.Contains(t, warning[slog.MessageKey],
|
||||||
|
"run 'vaultik snapshot remove' with the snapshot's ID again")
|
||||||
|
assert.Equal(t, stalePruneSnapshotID, warning["snapshot_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMissingDestinationConfig builds a config whose destination
|
||||||
|
// directory does not exist. Returns the config path and the path of
|
||||||
|
// its local index, which is not created here.
|
||||||
|
func writeMissingDestinationConfig(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
|
filepath.Join(dir, "source"),
|
||||||
|
filepath.Join(dir, "missing-store"),
|
||||||
|
indexPath)
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
|
// package init; point it at the temp dir so the test neither
|
||||||
|
// touches nor collides with the real one.
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||||
|
xdg.Reload()
|
||||||
|
t.Cleanup(xdg.Reload)
|
||||||
|
|
||||||
|
return configPath, indexPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUnusableDestinationConfig builds a config whose destination
|
||||||
|
// directory does not exist, which fails `remote info`, and whose local
|
||||||
|
// index is bound to another destination, which fails `prune` and
|
||||||
|
// `snapshot remove` (a missing destination alone only makes `snapshot
|
||||||
|
// remove` warn). Returns the config path.
|
||||||
|
func writeUnusableDestinationConfig(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath, indexPath := writeMissingDestinationConfig(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, database.NewRepositories(db).LocalMeta.Set(ctx,
|
||||||
|
database.LocalMetaKeyStorageURL, "file://"+t.TempDir()))
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEntry runs Entry with args after the program name and returns its
|
||||||
|
// exit code and what it wrote to stdout and stderr.
|
||||||
|
//
|
||||||
|
// Not parallel-safe: it replaces os.Args, os.Stdout and os.Stderr.
|
||||||
|
func runEntry(t *testing.T, args ...string) (int, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = append([]string{programName}, args...)
|
||||||
|
|
||||||
|
var code int
|
||||||
|
|
||||||
|
stdout, stderr := captureProcessStdoutAndStderr(t,
|
||||||
|
func() { code = Entry() })
|
||||||
|
|
||||||
|
return code, stdout, stderr
|
||||||
|
}
|
||||||
+31
-12
@@ -1,14 +1,18 @@
|
|||||||
package cli
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCLIEntry ensures the CLI can be imported and basic initialization works
|
// TestCLIEntry ensures the CLI can be imported and basic initialization works
|
||||||
func TestCLIEntry(t *testing.T) {
|
func TestCLIEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// This test primarily serves as a compilation test
|
// This test primarily serves as a compilation test
|
||||||
// to ensure all imports resolve correctly
|
// to ensure all imports resolve correctly
|
||||||
cmd := NewRootCommand()
|
cmd := cli.NewRootCommand()
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("NewRootCommand() returned nil")
|
t.Fatal("NewRootCommand() returned nil")
|
||||||
}
|
}
|
||||||
@@ -18,33 +22,48 @@ func TestCLIEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all subcommands are registered
|
// Verify all subcommands are registered
|
||||||
expectedCommands := []string{"backup", "restore", "prune", "verify", "fetch"}
|
expectedCommands := []string{
|
||||||
|
"config", "snapshot", "prune", "info", "version", "remote", "database",
|
||||||
|
}
|
||||||
for _, expected := range expectedCommands {
|
for _, expected := range expectedCommands {
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
for _, cmd := range cmd.Commands() {
|
for _, cmd := range cmd.Commands() {
|
||||||
if cmd.Use == expected || cmd.Name() == expected {
|
if cmd.Use == expected || cmd.Name() == expected {
|
||||||
found = true
|
found = true
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("Expected command '%s' not found", expected)
|
t.Errorf("Expected command '%s' not found", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify backup command has proper flags
|
// Verify snapshot command has subcommands
|
||||||
backupCmd, _, err := cmd.Find([]string{"backup"})
|
snapshotCmd, _, err := cmd.Find([]string{"snapshot"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to find backup command: %v", err)
|
t.Errorf("Failed to find snapshot command: %v", err)
|
||||||
} else {
|
} else {
|
||||||
if backupCmd.Flag("config") == nil {
|
// Check snapshot subcommands
|
||||||
t.Error("Backup command missing --config flag")
|
expectedSubCommands := []string{
|
||||||
|
"create", "list", "purge", "verify", "remove", "restore",
|
||||||
}
|
}
|
||||||
if backupCmd.Flag("daemon") == nil {
|
for _, expected := range expectedSubCommands {
|
||||||
t.Error("Backup command missing --daemon flag")
|
found := false
|
||||||
|
|
||||||
|
for _, subcmd := range snapshotCmd.Commands() {
|
||||||
|
if subcmd.Use == expected || subcmd.Name() == expected {
|
||||||
|
found = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected snapshot subcommand '%s' not found", expected)
|
||||||
}
|
}
|
||||||
if backupCmd.Flag("cron") == nil {
|
|
||||||
t.Error("Backup command missing --cron flag")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the command-word constants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntryGroupRejectsUnknownSubcommand runs each group command with a
|
||||||
|
// mistyped subcommand, as a crontab line might carry one. It must fail
|
||||||
|
// with exit 1 and name the word, as `vaultik bogus` does at the top
|
||||||
|
// level, instead of printing the group's help and exiting 0 having done
|
||||||
|
// nothing. The group name alone still prints its help.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||||
|
func TestEntryGroupRejectsUnknownSubcommand(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
group string
|
||||||
|
mistyped string
|
||||||
|
}{
|
||||||
|
{group: cmdSnapshot, mistyped: "craete"},
|
||||||
|
{group: "config", mistyped: "inti"},
|
||||||
|
{group: cmdRemote, mistyped: "ifno"},
|
||||||
|
{group: "database", mistyped: "delte"},
|
||||||
|
{group: "completion", mistyped: "nosuchshell"},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.group, func(t *testing.T) {
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
testCase.group, testCase.mistyped)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
`unknown command "`+testCase.mistyped+`"`)
|
||||||
|
|
||||||
|
code, stdout, _ = runEntry(t, testCase.group)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
programName+" "+testCase.group+" [command]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FetchOptions contains options for the fetch command
|
|
||||||
type FetchOptions struct {
|
|
||||||
Bucket string
|
|
||||||
Prefix string
|
|
||||||
SnapshotID string
|
|
||||||
FilePath string
|
|
||||||
Target string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFetchCommand creates the fetch command
|
|
||||||
func NewFetchCommand() *cobra.Command {
|
|
||||||
opts := &FetchOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "fetch",
|
|
||||||
Short: "Extract single file from backup",
|
|
||||||
Long: `Download and decrypt a single file from a backup snapshot`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// Validate required flags
|
|
||||||
if opts.Bucket == "" {
|
|
||||||
return fmt.Errorf("--bucket is required")
|
|
||||||
}
|
|
||||||
if opts.Prefix == "" {
|
|
||||||
return fmt.Errorf("--prefix is required")
|
|
||||||
}
|
|
||||||
if opts.SnapshotID == "" {
|
|
||||||
return fmt.Errorf("--snapshot is required")
|
|
||||||
}
|
|
||||||
if opts.FilePath == "" {
|
|
||||||
return fmt.Errorf("--file is required")
|
|
||||||
}
|
|
||||||
if opts.Target == "" {
|
|
||||||
return fmt.Errorf("--target is required")
|
|
||||||
}
|
|
||||||
return runFetch(cmd.Context(), opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name")
|
|
||||||
cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix")
|
|
||||||
cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID")
|
|
||||||
cmd.Flags().StringVar(&opts.FilePath, "file", "", "Path of file to extract from backup")
|
|
||||||
cmd.Flags().StringVar(&opts.Target, "target", "", "Target path for extracted file")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runFetch(ctx context.Context, opts *FetchOptions) error {
|
|
||||||
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
|
|
||||||
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.Supply(opts),
|
|
||||||
fx.Provide(globals.New),
|
|
||||||
// Additional modules will be added here
|
|
||||||
fx.Invoke(func(g *globals.Globals) error {
|
|
||||||
// TODO: Implement fetch logic
|
|
||||||
fmt.Printf("Fetching %s from snapshot %s to %s\n", opts.FilePath, opts.SnapshotID, opts.Target)
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
fx.NopLogger,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := app.Start(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed to start fetch: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := app.Stop(ctx); err != nil {
|
|
||||||
fmt.Printf("error stopping app: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewInfoCommand creates the info command
|
||||||
|
func NewInfoCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "info",
|
||||||
|
Short: "Display system and configuration information",
|
||||||
|
Long: `Shows information about the current vaultik configuration, including:
|
||||||
|
- System details (OS, architecture, version)
|
||||||
|
- Storage configuration (S3 bucket, endpoint)
|
||||||
|
- Backup settings (source directories, compression)
|
||||||
|
- Encryption configuration (recipients)
|
||||||
|
- Local database statistics`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Use unified config resolution
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the app framework
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
},
|
||||||
|
Mode: readOnly,
|
||||||
|
}, func(v *vaultik.Vaultik) error {
|
||||||
|
return v.ShowInfo()
|
||||||
|
}, func(err error) {
|
||||||
|
log.Error("Failed to show info", "error", err)
|
||||||
|
ReportErrorf("Failed to show info: %v", err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJSONHelpSaysConfirmationPromptIsSkipped checks that the --json help
|
||||||
|
// of `snapshot remove` and `prune` says the flag skips the confirmation
|
||||||
|
// prompt. Both delete without asking under --json, since a prompt on
|
||||||
|
// stdout would break the JSON document.
|
||||||
|
func TestJSONHelpSaysConfirmationPromptIsSkipped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, cmd := range []*cobra.Command{
|
||||||
|
newSnapshotRemoveCommand(),
|
||||||
|
NewPruneCommand(),
|
||||||
|
} {
|
||||||
|
assert.Contains(t, cmd.Flags().Lookup("json").Usage,
|
||||||
|
"skips the confirmation prompt", cmd.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-59
@@ -1,78 +1,78 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PruneOptions contains options for the prune command
|
|
||||||
type PruneOptions struct {
|
|
||||||
Bucket string
|
|
||||||
Prefix string
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPruneCommand creates the prune command
|
// NewPruneCommand creates the prune command
|
||||||
func NewPruneCommand() *cobra.Command {
|
func NewPruneCommand() *cobra.Command {
|
||||||
opts := &PruneOptions{}
|
opts := &vaultik.PruneOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "prune",
|
Use: "prune",
|
||||||
Short: "Remove unreferenced blobs",
|
Short: "Tidy local database and remote storage",
|
||||||
Long: `Delete blobs that are no longer referenced by any snapshot`,
|
Long: `Removes orphaned data from the local index database, and
|
||||||
|
unreferenced blobs and directories under metadata/ with no manifest
|
||||||
|
from the backup destination store.
|
||||||
|
|
||||||
|
Needs permission to list, read and delete objects on the destination
|
||||||
|
store. It does not need the age private key.
|
||||||
|
|
||||||
|
Local cleanup drops snapshots whose manifest is gone from the
|
||||||
|
destination store, incomplete snapshots, and any files, chunks, or
|
||||||
|
blobs no longer referenced by a completed snapshot. Remote cleanup
|
||||||
|
scans every snapshot manifest in the destination store, builds the
|
||||||
|
set of still-referenced blob hashes, and deletes any blob not in that
|
||||||
|
set that is older than prune_grace_period (default 24h). A newer one
|
||||||
|
is kept: it may belong to a backup still running, on this host or
|
||||||
|
another sharing the destination store, that has not yet uploaded its
|
||||||
|
manifest. The grace period does not protect a blob such a backup
|
||||||
|
reuses from an earlier run that stopped before its manifest: that
|
||||||
|
blob keeps the time of its first upload, so it can be deleted.
|
||||||
|
Remote cleanup also deletes each directory under metadata/ with no
|
||||||
|
manifest, as a backup that stopped before its manifest leaves, once
|
||||||
|
all its objects are older than prune_grace_period and its snapshot is
|
||||||
|
not in the local index.
|
||||||
|
|
||||||
|
Snapshot create --prune runs the same cleanup automatically; this
|
||||||
|
command is the manual entry point for the same work (e.g. after a
|
||||||
|
crashed backup or to reclaim storage). Snapshot remove leaves blobs in
|
||||||
|
place; run this command afterwards to delete the ones no longer
|
||||||
|
referenced.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
// Validate required flags
|
// Use unified config resolution
|
||||||
if opts.Bucket == "" {
|
configPath, err := ResolveConfigPath()
|
||||||
return fmt.Errorf("--bucket is required")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if opts.Prefix == "" {
|
|
||||||
return fmt.Errorf("--prefix is required")
|
// Use the app framework like other commands
|
||||||
}
|
rootFlags := GetRootFlags()
|
||||||
return runPrune(cmd.Context(), opts)
|
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
JSON: opts.JSON,
|
||||||
|
},
|
||||||
|
Mode: mutating,
|
||||||
|
}, func(v *vaultik.Vaultik) error {
|
||||||
|
return v.Prune(opts)
|
||||||
|
}, func(err error) {
|
||||||
|
log.Error("Prune operation failed", "error", err)
|
||||||
|
ReportErrorf("Prune failed: %v", err)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be deleted without actually deleting")
|
"Output pruning stats as JSON; skips the confirmation prompt, as --force does")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPrune(ctx context.Context, opts *PruneOptions) error {
|
|
||||||
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
|
|
||||||
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.Supply(opts),
|
|
||||||
fx.Provide(globals.New),
|
|
||||||
// Additional modules will be added here
|
|
||||||
fx.Invoke(func(g *globals.Globals) error {
|
|
||||||
// TODO: Implement prune logic
|
|
||||||
fmt.Printf("Pruning bucket %s with prefix %s\n", opts.Bucket, opts.Prefix)
|
|
||||||
if opts.DryRun {
|
|
||||||
fmt.Println("Running in dry-run mode")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
fx.NopLogger,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := app.Start(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed to start prune: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := app.Stop(ctx); err != nil {
|
|
||||||
fmt.Printf("error stopping app: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setRootFlags overrides the global rootFlags for the duration of one
|
||||||
|
// test and restores it afterward. These tests must not run in parallel:
|
||||||
|
// the flags are process-global, so the whole struct is saved and put
|
||||||
|
// back rather than left mutated for the next test.
|
||||||
|
func setRootFlags(t *testing.T, f RootFlags) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
old := rootFlags
|
||||||
|
rootFlags = f
|
||||||
|
|
||||||
|
t.Cleanup(func() { rootFlags = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFile writes content to a fresh file and returns its path.
|
||||||
|
func seedFile(t *testing.T, dir, name, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seeding %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustExecute runs a command with its output captured and fails the test
|
||||||
|
// if it errors, returning what the command printed.
|
||||||
|
func mustExecute(t *testing.T, cmd *cobra.Command, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s failed: %v", cmd.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersionQuietSuppressesReport checks that --quiet silences the whole
|
||||||
|
// version report: it is human-facing output, not a scriptable value.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestVersionQuietSuppressesReport(t *testing.T) {
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true})
|
||||||
|
|
||||||
|
out := mustExecute(t, NewVersionCommand())
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet version printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigGetIgnoresQuiet checks that a config value is printed even
|
||||||
|
// under --quiet: it is scriptable output a caller depends on, so --quiet
|
||||||
|
// must not suppress it, and it stays machine-plain (no marker, no color).
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigGetIgnoresQuiet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "config.yml", "storage_url: file:///mnt/x\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigGetCommand(), "storage_url")
|
||||||
|
|
||||||
|
if out != "file:///mnt/x\n" {
|
||||||
|
t.Errorf("config get --quiet = %q, want the plain value", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetQuietSuppressesConfirmation checks that --quiet silences
|
||||||
|
// the confirmation line while still writing the value to the file.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigSetQuietSuppressesConfirmation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet config set printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "compression_level: 9") {
|
||||||
|
t.Errorf("config set did not write the value under --quiet:\n%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetConfirmsWhenNotQuiet checks that the confirmation names
|
||||||
|
// the key (styled) when --quiet is not set.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigSetConfirmsWhenNotQuiet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
||||||
|
|
||||||
|
if !strings.Contains(out, "compression_level") {
|
||||||
|
t.Errorf("config set did not confirm the key: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigInitQuietSuppressesConfirmation checks that --quiet silences
|
||||||
|
// the "config written" confirmation while still writing the file.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigInitQuietSuppressesConfirmation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "new-config.yml")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigInitCommand())
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet config init printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("config init did not write the file under --quiet: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDatabaseDeleteConfig writes a valid config whose index_path is a
|
||||||
|
// seeded database file, and returns both paths.
|
||||||
|
func seedDatabaseDeleteConfig(t *testing.T, dir string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbPath := seedFile(t, dir, "index.sqlite", "not-a-real-db")
|
||||||
|
cfg := fmt.Sprintf(hermeticConfig,
|
||||||
|
filepath.Join(dir, "source"), filepath.Join(dir, "store"), dbPath)
|
||||||
|
cfgPath := seedFile(t, dir, "config.yml", cfg)
|
||||||
|
|
||||||
|
return dbPath, cfgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseDeleteQuietSuppressesMessage checks that --quiet silences
|
||||||
|
// the "database deleted" line while still removing the file.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestDatabaseDeleteQuietSuppressesMessage(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: cfgPath})
|
||||||
|
|
||||||
|
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet database delete printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(dbPath)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("database delete did not remove the file: stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseDeleteReportsWhenNotQuiet checks that the deletion is
|
||||||
|
// reported when --quiet is not set.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestDatabaseDeleteReportsWhenNotQuiet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
||||||
|
|
||||||
|
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||||
|
|
||||||
|
if !strings.Contains(out, "deleted") {
|
||||||
|
t.Errorf("database delete did not report the deletion: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errNukeNeedsForce guards the destructive 'remote nuke' subcommand.
|
||||||
|
var errNukeNeedsForce = errors.New(
|
||||||
|
"remote nuke requires --force (this deletes ALL remote snapshots and blobs)")
|
||||||
|
|
||||||
|
// NewRemoteCommand creates the remote command and subcommands
|
||||||
|
func NewRemoteCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remote",
|
||||||
|
Short: "Remote storage management commands",
|
||||||
|
Long: "Commands for inspecting and managing remote storage",
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
cmd.AddCommand(newRemoteInfoCommand())
|
||||||
|
cmd.AddCommand(newRemoteNukeCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRemoteNukeCommand creates the 'remote nuke' subcommand.
|
||||||
|
func newRemoteNukeCommand() *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "nuke",
|
||||||
|
Short: "Delete ALL snapshot metadata and blobs from the backup destination store",
|
||||||
|
Long: `Removes every snapshot's metadata, every blob and the destination
|
||||||
|
marker from the backup destination store, and each removed snapshot from
|
||||||
|
the local index. After this command completes successfully the bucket
|
||||||
|
prefix is empty and the next backup starts from scratch.
|
||||||
|
|
||||||
|
This is destructive and irreversible. Requires --force, and permission
|
||||||
|
to list and delete objects on the destination store.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
if !force {
|
||||||
|
return errNukeNeedsForce
|
||||||
|
}
|
||||||
|
|
||||||
|
return runVaultikApp(cmd, mutating, false, "Remote nuke failed",
|
||||||
|
func(v *vaultik.Vaultik) error {
|
||||||
|
return v.NukeRemote(true)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&force, "force", false,
|
||||||
|
"Required: confirm destruction of ALL remote data")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRemoteInfoCommand creates the 'remote info' subcommand
|
||||||
|
func newRemoteInfoCommand() *cobra.Command {
|
||||||
|
var jsonOutput bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "info",
|
||||||
|
Short: "Display remote storage information",
|
||||||
|
Long: `Shows detailed information about remote storage, including:
|
||||||
|
- Size of all snapshot metadata (per snapshot and total)
|
||||||
|
- Count and total size of all blobs
|
||||||
|
- Count and size of referenced blobs (from all manifests)
|
||||||
|
- Count and size of orphaned blobs (not referenced by any manifest)
|
||||||
|
|
||||||
|
Needs permission to list and read the destination store. It does not
|
||||||
|
need the age private key.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Use unified config resolution
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
JSON: jsonOutput,
|
||||||
|
},
|
||||||
|
Mode: readOnly,
|
||||||
|
}, func(v *vaultik.Vaultik) error {
|
||||||
|
return v.RemoteInfo(jsonOutput)
|
||||||
|
}, func(err error) {
|
||||||
|
log.Error("Failed to get remote info", "error", err)
|
||||||
|
ReportErrorf("Failed to get remote info: %v", err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RestoreOptions contains options for the restore command
|
|
||||||
type RestoreOptions struct {
|
|
||||||
Bucket string
|
|
||||||
Prefix string
|
|
||||||
SnapshotID string
|
|
||||||
TargetDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRestoreCommand creates the restore command
|
|
||||||
func NewRestoreCommand() *cobra.Command {
|
|
||||||
opts := &RestoreOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "restore",
|
|
||||||
Short: "Restore files from backup",
|
|
||||||
Long: `Download and decrypt files from a backup snapshot`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// Validate required flags
|
|
||||||
if opts.Bucket == "" {
|
|
||||||
return fmt.Errorf("--bucket is required")
|
|
||||||
}
|
|
||||||
if opts.Prefix == "" {
|
|
||||||
return fmt.Errorf("--prefix is required")
|
|
||||||
}
|
|
||||||
if opts.SnapshotID == "" {
|
|
||||||
return fmt.Errorf("--snapshot is required")
|
|
||||||
}
|
|
||||||
if opts.TargetDir == "" {
|
|
||||||
return fmt.Errorf("--target is required")
|
|
||||||
}
|
|
||||||
return runRestore(cmd.Context(), opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name")
|
|
||||||
cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix")
|
|
||||||
cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID to restore")
|
|
||||||
cmd.Flags().StringVar(&opts.TargetDir, "target", "", "Target directory for restore")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRestore(ctx context.Context, opts *RestoreOptions) error {
|
|
||||||
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
|
|
||||||
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.Supply(opts),
|
|
||||||
fx.Provide(globals.New),
|
|
||||||
// Additional modules will be added here
|
|
||||||
fx.Invoke(func(g *globals.Globals) error {
|
|
||||||
// TODO: Implement restore logic
|
|
||||||
fmt.Printf("Restoring snapshot %s to %s\n", opts.SnapshotID, opts.TargetDir)
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
fx.NopLogger,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := app.Start(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed to start restore: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := app.Stop(ctx); err != nil {
|
|
||||||
fmt.Printf("error stopping app: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+159
-9
@@ -1,29 +1,179 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRootCommand creates the root cobra command
|
// errConfigNotFound is wrapped by all config-resolution failures.
|
||||||
|
var errConfigNotFound = errors.New("config file not found")
|
||||||
|
|
||||||
|
// RootFlags holds global flags that apply to all commands.
|
||||||
|
// These flags are defined on the root command and inherited by all subcommands.
|
||||||
|
type RootFlags struct {
|
||||||
|
ConfigPath string
|
||||||
|
Verbose bool
|
||||||
|
Debug bool
|
||||||
|
Quiet bool
|
||||||
|
SkipErrors bool
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gochecknoglobals // cobra persistent flags bind to package state
|
||||||
|
var rootFlags RootFlags
|
||||||
|
|
||||||
|
// NewRootCommand creates the root cobra command for the vaultik CLI.
|
||||||
|
// It sets up the command structure, global flags, and adds all subcommands.
|
||||||
|
// This is the main entry point for the CLI command hierarchy.
|
||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "vaultik",
|
Use: "vaultik",
|
||||||
Short: "Secure incremental backup tool with asymmetric encryption",
|
Short: "Secure incremental backup tool with asymmetric encryption",
|
||||||
Long: `vaultik is a secure incremental backup daemon that encrypts data using age
|
Long: `vaultik is an incremental backup tool that encrypts data to age public
|
||||||
public keys and uploads to S3-compatible storage. No private keys are needed
|
keys and uploads it to an S3-compatible object store, a local directory or
|
||||||
on the source system.`,
|
an rclone remote. The backed-up machine needs no private key: only
|
||||||
|
'snapshot restore' and 'snapshot verify --deep' need one.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
// Bare 'vaultik' (no subcommand): print help. The banner is
|
||||||
|
// printed once at process startup by Entry, before cobra
|
||||||
|
// parses arguments, so it appears even when cobra rejects
|
||||||
|
// args (e.g. "requires at least 2 arg(s)") and on --help.
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add global flags
|
||||||
|
cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "",
|
||||||
|
"Path to config file (default: $VAULTIK_CONFIG, then the platform "+
|
||||||
|
"config directory, then /etc/vaultik/config.yml)")
|
||||||
|
cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false,
|
||||||
|
"Enable verbose output")
|
||||||
|
cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false,
|
||||||
|
"Enable debug output")
|
||||||
|
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
|
||||||
|
"Suppress status output (warnings, errors, JSON documents, "+
|
||||||
|
"'config get' values and confirmation prompts are still printed)")
|
||||||
|
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
||||||
|
"Skip files that cannot be read when creating a snapshot, or "+
|
||||||
|
"that cannot be restored when restoring, instead of aborting "+
|
||||||
|
"(packing and storage errors while creating a snapshot still "+
|
||||||
|
"abort)")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewBackupCommand(),
|
NewConfigCommand(),
|
||||||
NewRestoreCommand(),
|
|
||||||
NewPruneCommand(),
|
NewPruneCommand(),
|
||||||
NewVerifyCommand(),
|
NewSnapshotCommand(),
|
||||||
NewFetchCommand(),
|
NewInfoCommand(),
|
||||||
SnapshotCmd(),
|
NewVersionCommand(),
|
||||||
|
NewRemoteCommand(),
|
||||||
|
NewDatabaseCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cobra adds the completion command only when Execute runs, and with
|
||||||
|
// no Run, so it skipped its own cobra.NoArgs and a mistyped shell name
|
||||||
|
// printed help and exited 0. Adding it here lets it have a Run. Its
|
||||||
|
// scripts go to os.Stdout as it is now: a later SetOut does not
|
||||||
|
// redirect them.
|
||||||
|
cmd.InitDefaultCompletionCmd()
|
||||||
|
|
||||||
|
for _, sub := range cmd.Commands() {
|
||||||
|
if sub.Name() == "completion" {
|
||||||
|
sub.Run = func(completion *cobra.Command, _ []string) {
|
||||||
|
_ = completion.Help()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRootFlags returns the global flags that were parsed from the command line.
|
||||||
|
// This allows subcommands to access global flag values like verbosity and config path.
|
||||||
|
func GetRootFlags() RootFlags {
|
||||||
|
return rootFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandUI returns a UI writer for a command's stdout, in quiet mode
|
||||||
|
// when the global --quiet flag is set. This is how the pure-cli
|
||||||
|
// commands (version, config, database) reach internal/ui: color follows
|
||||||
|
// the writer (a TTY gets color, a captured test buffer does not), and
|
||||||
|
// --quiet silences the same message classes it silences everywhere else.
|
||||||
|
func commandUI(cmd *cobra.Command) *ui.Writer {
|
||||||
|
w := ui.New(cmd.OutOrStdout())
|
||||||
|
if GetRootFlags().Quiet {
|
||||||
|
w.SetQuiet(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveConfigPath resolves the config file path from flags, environment, or default.
|
||||||
|
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
||||||
|
// /etc/vaultik/config.yml.
|
||||||
|
// Explicit paths from --config and $VAULTIK_CONFIG are checked for existence
|
||||||
|
// so the user gets a clear error instead of a downstream YAML parser failure.
|
||||||
|
func ResolveConfigPath() (string, error) {
|
||||||
|
if path := rootFlags.ConfigPath; path != "" {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: from --config: %s (run 'vaultik config init --config %s' to create it)",
|
||||||
|
errConfigNotFound, path, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if path := os.Getenv("VAULTIK_CONFIG"); path != "" {
|
||||||
|
_, err := os.Stat(path) //nolint:gosec // G703: path is operator-supplied by design
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: from $VAULTIK_CONFIG: %s (unset VAULTIK_CONFIG, point it at "+
|
||||||
|
"an existing file, or run 'vaultik config init')",
|
||||||
|
errConfigNotFound, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range defaultConfigPaths() {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: searched %s (run 'vaultik config init' to create the default "+
|
||||||
|
"config, or pass --config <path>)",
|
||||||
|
errConfigNotFound, strings.Join(defaultConfigPaths(), " or "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultConfigPaths returns the ordered list of config paths to search.
|
||||||
|
// On macOS: ~/Library/Application Support/vaultik/config.yml
|
||||||
|
// On Linux: ~/.config/vaultik/config.yml
|
||||||
|
// Fallback: /etc/vaultik/config.yml
|
||||||
|
func defaultConfigPaths() []string {
|
||||||
|
return []string{
|
||||||
|
filepath.Join(xdg.ConfigHome, "vaultik", "config.yml"),
|
||||||
|
"/etc/vaultik/config.yml",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the platform-appropriate default config path.
|
||||||
|
// Used by the init command and in help text.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
return "/etc/vaultik/config.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(xdg.ConfigHome, "vaultik", "config.yml")
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunAppWaitsForOperationCleanupOnShutdown drives RunApp with an fx app
|
||||||
|
// wired the way RunOperation wires a command: a single lifecycle hook whose
|
||||||
|
// OnStart launches the operation in its own goroutine and whose OnStop cancels
|
||||||
|
// it and blocks until that goroutine returns. The operation stands in for a
|
||||||
|
// restore blocked mid-download — it holds a decrypted "scratch" file and only
|
||||||
|
// removes it as it unwinds on cancellation.
|
||||||
|
//
|
||||||
|
// The app is asked to stop once the operation is running (standing in for an
|
||||||
|
// OS interrupt; fx delivers a real signal and Shutdowner.Shutdown() on the
|
||||||
|
// same app.Wait channel, so both drive the identical shutdown path). RunApp
|
||||||
|
// must not return until app.Stop has run the OnStop hook, so the scratch file
|
||||||
|
// must be gone by the time RunApp returns. Before the fix RunApp returned as
|
||||||
|
// soon as the app.Wait/Done channel fired, without running app.Stop, so the
|
||||||
|
// cleanup never ran and this file would still be on disk (issue #159).
|
||||||
|
func TestRunAppWaitsForOperationCleanupOnShutdown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
scratch := filepath.Join(t.TempDir(), "decrypted-scratch")
|
||||||
|
require.NoError(t, os.WriteFile(scratch, []byte("secret"), 0o600))
|
||||||
|
|
||||||
|
// Cancel and reap the operation even if RunApp returns without doing so
|
||||||
|
// (the buggy path), so the goroutine cannot leak past the test.
|
||||||
|
opCtx, opCancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(opCancel)
|
||||||
|
|
||||||
|
var stop func(context.Context) bool
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Invoke(func(lc fx.Lifecycle, sh fx.Shutdowner) {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
// Blocked mid-operation until cancelled, then run the
|
||||||
|
// cleanup an interrupted restore would run.
|
||||||
|
<-opCtx.Done()
|
||||||
|
|
||||||
|
_ = os.Remove(scratch)
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop = func(ctx context.Context) bool {
|
||||||
|
opCancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the app to stop now that the operation is running.
|
||||||
|
go func() { _ = sh.Shutdown() }()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
stop(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cli.RunApp(context.Background(), app) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
t.Fatal("RunApp did not return after shutdown was requested")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(scratch)
|
||||||
|
require.True(t, os.IsNotExist(err),
|
||||||
|
"RunApp returned before the operation removed its decrypted scratch file")
|
||||||
|
}
|
||||||
+331
-50
@@ -1,90 +1,371 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SnapshotCmd() *cobra.Command {
|
var (
|
||||||
cmd := &cobra.Command{
|
errSnapshotIDRequired = errors.New("snapshot ID required")
|
||||||
Use: "snapshot",
|
errWrongArgCount = errors.New("wrong argument count")
|
||||||
Short: "Manage snapshots",
|
errPurgeCriteriaNeeded = errors.New(
|
||||||
Long: "Commands for listing, removing, and querying snapshots",
|
"must specify either --keep-latest or --older-than")
|
||||||
|
errPurgeCriteriaBoth = errors.New(
|
||||||
|
"cannot specify both --keep-latest and --older-than")
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireSnapshotIDArg validates that exactly one positional argument
|
||||||
|
// (the snapshot ID) was supplied, printing help otherwise.
|
||||||
|
func requireSnapshotIDArg(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) != 1 {
|
||||||
|
_ = cmd.Help()
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errSnapshotIDRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(snapshotListCmd())
|
return fmt.Errorf("%w: expected 1 argument, got %d",
|
||||||
cmd.AddCommand(snapshotRmCmd())
|
errWrongArgCount, len(args))
|
||||||
cmd.AddCommand(snapshotLatestCmd())
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSnapshotCommand creates the snapshot command and subcommands
|
||||||
|
func NewSnapshotCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "snapshot",
|
||||||
|
Short: "Snapshot management commands",
|
||||||
|
Long: "Commands for creating, listing, and managing snapshots",
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
cmd.AddCommand(newSnapshotCreateCommand())
|
||||||
|
cmd.AddCommand(newSnapshotListCommand())
|
||||||
|
cmd.AddCommand(newSnapshotPurgeCommand())
|
||||||
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
||||||
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
||||||
|
cmd.AddCommand(newSnapshotRestoreCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotListCmd() *cobra.Command {
|
// snapshotCreateLong is the long help text of 'snapshot create'.
|
||||||
var (
|
const snapshotCreateLong = `Creates new snapshots of the configured directories.
|
||||||
bucket string
|
|
||||||
prefix string
|
If snapshot names are provided, only those snapshots are created.
|
||||||
limit int
|
If no names are provided, all configured snapshots are created.
|
||||||
)
|
|
||||||
|
Needs at least one public key in age_recipients, and permission to write
|
||||||
|
objects at the backup destination store. It does not need the age private
|
||||||
|
key. With --prune it also needs permission to list, read and delete objects
|
||||||
|
there.
|
||||||
|
|
||||||
|
The config is read from the path given by --config or VAULTIK_CONFIG;
|
||||||
|
otherwise from the platform config directory (~/.config/vaultik/config.yml
|
||||||
|
on Linux), then /etc/vaultik/config.yml.`
|
||||||
|
|
||||||
|
// newSnapshotCreateCommand creates the 'snapshot create' subcommand
|
||||||
|
func newSnapshotCreateCommand() *cobra.Command {
|
||||||
|
opts := &vaultik.SnapshotCreateOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create [snapshot-names...]",
|
||||||
|
Short: "Create new snapshots",
|
||||||
|
Long: snapshotCreateLong,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
err := opts.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass snapshot names from args
|
||||||
|
opts.Snapshots = args
|
||||||
|
// --skip-errors is a global flag on the root command.
|
||||||
|
opts.SkipErrors = rootFlags.SkipErrors
|
||||||
|
// Use unified config resolution
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the backup functionality from cli package
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
|
// --cron suppression is wired through v.UI by setupGlobals.
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Cron: opts.Cron,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
},
|
||||||
|
Mode: mutating,
|
||||||
|
}, func(v *vaultik.Vaultik) error {
|
||||||
|
return v.CreateSnapshot(opts)
|
||||||
|
}, func(err error) {
|
||||||
|
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
|
||||||
|
log.Error("Post-backup prune failed", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
|
||||||
|
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
|
||||||
|
errors.Is(err, vaultik.ErrVolumeNotMounted) {
|
||||||
|
log.Error("Backup completed with an error", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
||||||
|
"Run in cron mode (silent unless warning or error)")
|
||||||
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||||
|
"After backup, drop older snapshots of the same name, remove "+
|
||||||
|
"orphaned blobs older than prune_grace_period, and delete each "+
|
||||||
|
"directory under metadata/ with no manifest once all its "+
|
||||||
|
"objects are older than prune_grace_period and its snapshot is "+
|
||||||
|
"not in the local index (not after a backup that skipped "+
|
||||||
|
"unreadable files, found a configured path empty, or did not "+
|
||||||
|
"find a mount point an earlier snapshot found)")
|
||||||
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||||
|
"With --prune: keep snapshots newer than this duration "+
|
||||||
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||||
|
"instead of only the latest")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSnapshotListCommand creates the 'snapshot list' subcommand
|
||||||
|
func newSnapshotListCommand() *cobra.Command {
|
||||||
|
var jsonOutput bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List snapshots",
|
Aliases: []string{"ls"},
|
||||||
Long: "List all snapshots in the bucket, sorted by timestamp",
|
Short: "List all snapshots",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: `Lists the snapshots in the local index and on the backup destination
|
||||||
panic("unimplemented")
|
store, with their ID, timestamp, and sizes.
|
||||||
|
|
||||||
|
Needs permission to list and read the destination store. It does not need
|
||||||
|
the age private key. A snapshot found only on the destination store is
|
||||||
|
listed by its remote key, because its ID is stored only in the local index
|
||||||
|
and in the snapshot's encrypted database. If the destination store cannot
|
||||||
|
be listed, the command warns and lists the local index alone.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return runVaultikApp(cmd, readOnly, false,
|
||||||
|
"Failed to list snapshots",
|
||||||
|
func(v *vaultik.Vaultik) error {
|
||||||
|
return v.ListSnapshots(jsonOutput)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&bucket, "bucket", "", "S3 bucket name")
|
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||||
cmd.Flags().StringVar(&prefix, "prefix", "", "S3 prefix")
|
|
||||||
cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of snapshots to list")
|
|
||||||
cmd.MarkFlagRequired("bucket")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotRmCmd() *cobra.Command {
|
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
||||||
var (
|
func newSnapshotPurgeCommand() *cobra.Command {
|
||||||
bucket string
|
opts := &vaultik.SnapshotPurgeOptions{}
|
||||||
prefix string
|
|
||||||
snapshot string
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm",
|
Use: "purge",
|
||||||
Short: "Remove a snapshot",
|
Short: "Purge old snapshots",
|
||||||
Long: "Remove a snapshot and optionally its associated blobs",
|
Long: `Removes snapshots based on age or count criteria. Exactly one of
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
--keep-latest and --older-than is required.
|
||||||
panic("unimplemented")
|
|
||||||
|
Retention is per-snapshot-name: --keep-latest keeps the latest snapshot of
|
||||||
|
each name, not the latest globally. Use --snapshot to restrict the
|
||||||
|
operation to specific snapshot names.
|
||||||
|
|
||||||
|
Only snapshots in the local index are removed. Each one is removed from
|
||||||
|
the local index and its metadata is deleted from the backup destination
|
||||||
|
store. Blobs are not touched: run 'vaultik prune' afterwards.
|
||||||
|
|
||||||
|
Needs permission to list and delete objects on the destination store. If
|
||||||
|
the destination store cannot be listed, nothing is removed.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Validate flags
|
||||||
|
if !opts.KeepLatest && opts.OlderThan == "" {
|
||||||
|
return errPurgeCriteriaNeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.KeepLatest && opts.OlderThan != "" {
|
||||||
|
return errPurgeCriteriaBoth
|
||||||
|
}
|
||||||
|
|
||||||
|
return runVaultikApp(cmd, mutating, false,
|
||||||
|
"Failed to purge snapshots",
|
||||||
|
func(v *vaultik.Vaultik) error {
|
||||||
|
return v.PurgeSnapshotsWithOptions(opts)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&bucket, "bucket", "", "S3 bucket name")
|
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false,
|
||||||
cmd.Flags().StringVar(&prefix, "prefix", "", "S3 prefix")
|
"Keep only the latest snapshot of each name")
|
||||||
cmd.Flags().StringVar(&snapshot, "snapshot", "", "Snapshot ID to remove")
|
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
|
||||||
cmd.MarkFlagRequired("bucket")
|
"Remove snapshots older than duration "+
|
||||||
cmd.MarkFlagRequired("snapshot")
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months)")
|
||||||
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
|
cmd.Flags().StringArrayVar(&opts.Names, "snapshot", nil,
|
||||||
|
"Restrict to snapshots with these names (repeat for multiple)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotLatestCmd() *cobra.Command {
|
// newSnapshotVerifyCommand creates the 'snapshot verify' subcommand
|
||||||
var (
|
func newSnapshotVerifyCommand() *cobra.Command {
|
||||||
bucket string
|
opts := &vaultik.VerifyOptions{}
|
||||||
prefix string
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "latest",
|
Use: "verify <snapshot-id>",
|
||||||
Short: "Get the latest snapshot ID",
|
Short: "Check a snapshot's blobs are present with the listed size",
|
||||||
Long: "Display the ID of the most recent snapshot",
|
Long: `Checks that every blob the snapshot's manifest lists is present
|
||||||
|
in storage with the size the manifest records, and that the
|
||||||
|
snapshot's encrypted database is present. It does not read blob
|
||||||
|
contents and does not need the age private key.
|
||||||
|
|
||||||
|
Needs permission to read the backup destination store, and to list it
|
||||||
|
when a remote key is given.
|
||||||
|
|
||||||
|
Use --deep to download, decrypt, and re-hash every blob to detect
|
||||||
|
corruption -- integrity, not who wrote it.
|
||||||
|
|
||||||
|
--deep requires the age private key, in the VAULTIK_AGE_SECRET_KEY
|
||||||
|
environment variable or as age_secret_key in the config file; the
|
||||||
|
environment variable wins. The variable may hold the whole age-keygen
|
||||||
|
file, with its comments and all of its keys. Read the key from the
|
||||||
|
file rather than typing it, so that it does not land in your shell
|
||||||
|
history:
|
||||||
|
|
||||||
|
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||||
|
|
||||||
|
The snapshot may be named by its ID or, on a host with no local
|
||||||
|
index, by the remote key that 'snapshot list' prints for a
|
||||||
|
remote-only snapshot (an unambiguous leading part is enough).`,
|
||||||
|
Args: requireSnapshotIDArg,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
panic("unimplemented")
|
snapshotID := args[0]
|
||||||
|
|
||||||
|
// Use unified config resolution
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
JSON: opts.JSON,
|
||||||
|
},
|
||||||
|
Mode: readOnly,
|
||||||
|
}, func(v *vaultik.Vaultik) error {
|
||||||
|
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
||||||
|
}, func(err error) {
|
||||||
|
if opts.JSON {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("Verification failed", "error", err)
|
||||||
|
ReportErrorf("Verification failed: %v", err)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&bucket, "bucket", "", "S3 bucket name")
|
cmd.Flags().BoolVar(&opts.Deep, "deep", false,
|
||||||
cmd.Flags().StringVar(&prefix, "prefix", "", "S3 prefix")
|
"Download, decrypt and re-hash every blob; requires the age private "+
|
||||||
cmd.MarkFlagRequired("bucket")
|
"key in VAULTIK_AGE_SECRET_KEY, or as age_secret_key in the config "+
|
||||||
|
"file")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSnapshotRemoveCommand creates the 'snapshot remove' subcommand
|
||||||
|
func newSnapshotRemoveCommand() *cobra.Command {
|
||||||
|
opts := &vaultik.RemoveOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <snapshot-id>",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Short: "Remove a snapshot from local index and remote metadata",
|
||||||
|
Long: `Removes a snapshot.
|
||||||
|
|
||||||
|
By default, this removes the snapshot from the local index database and
|
||||||
|
strips the snapshot's metadata from the backup destination store. Blobs
|
||||||
|
are NOT touched: deleting them requires reading every remaining remote
|
||||||
|
manifest (the destination store may hold snapshots this host doesn't
|
||||||
|
know about), which is what 'vaultik prune' does. The command prints the
|
||||||
|
'vaultik prune' invocation to run as a follow-up only when it removed the
|
||||||
|
snapshot's metadata from the destination store, and never under --json.
|
||||||
|
|
||||||
|
The argument must be a snapshot ID: a remote key is not accepted. An ID
|
||||||
|
that names no snapshot in the local index or on the destination store is
|
||||||
|
an error. Needs permission to list and delete objects on the destination
|
||||||
|
store, unless --local-only is given.
|
||||||
|
|
||||||
|
Use --local-only to skip the remote half (e.g. when you want to forget a
|
||||||
|
snapshot locally without touching the destination store); the snapshot
|
||||||
|
must then be in the local index.
|
||||||
|
|
||||||
|
If removing the snapshot's metadata from the destination store fails (for
|
||||||
|
example because the remote is unreachable), the local-database removal
|
||||||
|
still completes and a warning is emitted; run 'vaultik snapshot remove
|
||||||
|
<snapshot-id>' again once the destination store is reachable to remove
|
||||||
|
the snapshot's metadata from it ('vaultik prune' does not). For a
|
||||||
|
snapshot that is only on the destination store, that failure is an error.
|
||||||
|
|
||||||
|
To wipe the entire destination store and start over, use 'vaultik remote
|
||||||
|
nuke --force' — it is the single supported entry point for that.`,
|
||||||
|
Args: requireSnapshotIDArg,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runVaultikApp(cmd, mutating, opts.JSON,
|
||||||
|
"Failed to remove snapshot",
|
||||||
|
func(v *vaultik.Vaultik) error {
|
||||||
|
_, err := v.RemoveSnapshot(args[0], opts)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||||
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
|
"Show what would be removed without removing")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output result as JSON; skips the confirmation prompt, as --force does")
|
||||||
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
||||||
|
"Skip remote cleanup; only touch the local index")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restoreMinArgs is the minimum positional argument count of
|
||||||
|
// `snapshot restore <snapshot-id> <target-dir> [paths...]`.
|
||||||
|
const restoreMinArgs = 2
|
||||||
|
|
||||||
|
// RestoreOptions contains options for the restore command
|
||||||
|
type RestoreOptions struct {
|
||||||
|
TargetDir string
|
||||||
|
Paths []string // Optional paths to restore (empty = all)
|
||||||
|
Verify bool // Verify restored files after restore
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSnapshotRestoreCommand creates the 'snapshot restore' subcommand
|
||||||
|
func newSnapshotRestoreCommand() *cobra.Command {
|
||||||
|
opts := &RestoreOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "restore <snapshot-id> <target-dir> [paths...]",
|
||||||
|
Short: "Restore files from a snapshot",
|
||||||
|
Long: `Download and decrypt files from a backup snapshot.
|
||||||
|
|
||||||
|
This command will restore files from the specified snapshot to the
|
||||||
|
target directory.
|
||||||
|
If no paths are specified, all files are restored.
|
||||||
|
If paths are specified, only matching files/directories are restored.
|
||||||
|
A path that matches nothing in the snapshot is named in a warning, and
|
||||||
|
the command exits non-zero after restoring the paths that match.
|
||||||
|
|
||||||
|
The snapshot may be named by its ID or, when restoring on a host with no
|
||||||
|
local index, by the remote key that 'snapshot list' prints for a
|
||||||
|
remote-only snapshot (an unambiguous leading part is enough).
|
||||||
|
|
||||||
|
Each file is restored under its original absolute path inside the target
|
||||||
|
directory. File owners are restored only when the command runs as root.
|
||||||
|
|
||||||
|
Requires the age private key, in the VAULTIK_AGE_SECRET_KEY environment
|
||||||
|
variable or as age_secret_key in the config file; the environment
|
||||||
|
variable wins. The variable may hold the whole age-keygen file, with its
|
||||||
|
comments and all of its keys. Read the key from the file rather than
|
||||||
|
typing it, so that it does not land in your shell history:
|
||||||
|
|
||||||
|
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||||
|
|
||||||
|
Also needs permission to read the backup destination store (and to list
|
||||||
|
it when a remote key is given), and free space in the temporary directory
|
||||||
|
for the decrypted blobs.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Restore entire snapshot
|
||||||
|
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore
|
||||||
|
|
||||||
|
# Restore specific file
|
||||||
|
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore \
|
||||||
|
/home/user/important.txt
|
||||||
|
|
||||||
|
# Restore specific directory
|
||||||
|
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore \
|
||||||
|
/home/user/documents/
|
||||||
|
|
||||||
|
# Restore and verify all files
|
||||||
|
vaultik snapshot restore --verify myhost_docs_2025-01-01T12:00:00Z /restore`,
|
||||||
|
Args: cobra.MinimumNArgs(restoreMinArgs),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runRestore(cmd, args, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&opts.Verify, "verify", false,
|
||||||
|
"Verify restored files by checking chunk hashes")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRestore parses arguments and runs the restore operation through the
|
||||||
|
// app framework.
|
||||||
|
func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
||||||
|
snapshotID := args[0]
|
||||||
|
|
||||||
|
opts.TargetDir = args[1]
|
||||||
|
if len(args) > restoreMinArgs {
|
||||||
|
opts.Paths = args[restoreMinArgs:]
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.Options{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
},
|
||||||
|
Mode: readOnly,
|
||||||
|
}, func(v *vaultik.Vaultik) error {
|
||||||
|
return v.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: opts.TargetDir,
|
||||||
|
Paths: opts.Paths,
|
||||||
|
Verify: opts.Verify,
|
||||||
|
SkipErrors: rootFlags.SkipErrors,
|
||||||
|
})
|
||||||
|
}, func(err error) {
|
||||||
|
log.Error("Restore operation failed", "error", err)
|
||||||
|
ReportErrorf("Restore failed: %v", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRestoreCommandDoesNotTakeKeyAsArgument guards the fix for the age
|
||||||
|
// key being echoed on the command line: restore must take the key only
|
||||||
|
// from the environment, never as a flag value, and its help must show the
|
||||||
|
// file-based form rather than a literal key that would land in shell
|
||||||
|
// history.
|
||||||
|
func TestRestoreCommandDoesNotTakeKeyAsArgument(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cmd := newSnapshotRestoreCommand()
|
||||||
|
|
||||||
|
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
lower := strings.ToLower(f.Name)
|
||||||
|
for _, banned := range []string{"key", "secret", "age", "identity"} {
|
||||||
|
if strings.Contains(lower, banned) {
|
||||||
|
t.Errorf("restore must not accept the key as a flag; found --%s", f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
help := cmd.Long
|
||||||
|
if strings.Contains(help, "AGE-SECRET-KEY-") {
|
||||||
|
t.Error("restore help must not show a literal age private key to type")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(help, "$(cat ") {
|
||||||
|
t.Error("restore help should read the key from a file, e.g. $(cat ...)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VerifyOptions contains options for the verify command
|
|
||||||
type VerifyOptions struct {
|
|
||||||
Bucket string
|
|
||||||
Prefix string
|
|
||||||
SnapshotID string
|
|
||||||
Quick bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVerifyCommand creates the verify command
|
|
||||||
func NewVerifyCommand() *cobra.Command {
|
|
||||||
opts := &VerifyOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "verify",
|
|
||||||
Short: "Verify backup integrity",
|
|
||||||
Long: `Check that all referenced blobs exist and verify metadata integrity`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// Validate required flags
|
|
||||||
if opts.Bucket == "" {
|
|
||||||
return fmt.Errorf("--bucket is required")
|
|
||||||
}
|
|
||||||
if opts.Prefix == "" {
|
|
||||||
return fmt.Errorf("--prefix is required")
|
|
||||||
}
|
|
||||||
return runVerify(cmd.Context(), opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name")
|
|
||||||
cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix")
|
|
||||||
cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID to verify (optional, defaults to latest)")
|
|
||||||
cmd.Flags().BoolVar(&opts.Quick, "quick", false, "Perform quick verification by checking blob existence and S3 content hashes without downloading")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runVerify(ctx context.Context, opts *VerifyOptions) error {
|
|
||||||
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
|
|
||||||
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.Supply(opts),
|
|
||||||
fx.Provide(globals.New),
|
|
||||||
// Additional modules will be added here
|
|
||||||
fx.Invoke(func(g *globals.Globals) error {
|
|
||||||
// TODO: Implement verify logic
|
|
||||||
if opts.SnapshotID == "" {
|
|
||||||
fmt.Printf("Verifying latest snapshot in bucket %s with prefix %s\n", opts.Bucket, opts.Prefix)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Verifying snapshot %s in bucket %s with prefix %s\n", opts.SnapshotID, opts.Bucket, opts.Prefix)
|
|
||||||
}
|
|
||||||
if opts.Quick {
|
|
||||||
fmt.Println("Performing quick verification")
|
|
||||||
} else {
|
|
||||||
fmt.Println("Performing deep verification")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
fx.NopLogger,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := app.Start(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed to start verify: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := app.Stop(ctx); err != nil {
|
|
||||||
fmt.Printf("error stopping app: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewVersionCommand creates the version command
|
||||||
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version information",
|
||||||
|
Long: `Print version, git commit, and build information for vaultik.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
writeVersion(commandUI(cmd))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeVersion prints the version report through the UI writer. The
|
||||||
|
// report is the output this command exists to produce, so it is written
|
||||||
|
// plain (markers would corrupt the aligned report) via the writer's
|
||||||
|
// underlying stdout; --quiet silences it like any other non-error
|
||||||
|
// output.
|
||||||
|
func writeVersion(out *ui.Writer) {
|
||||||
|
if out.Quiet() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := out.Out()
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||||
|
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||||
|
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
||||||
|
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
||||||
|
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||||
|
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
||||||
|
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
||||||
|
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
||||||
|
|
||||||
|
if globals.IsDevVersion(globals.Version) {
|
||||||
|
_, _ = fmt.Fprintln(w)
|
||||||
|
_, _ = fmt.Fprintln(w,
|
||||||
|
"This is a development build: it was not built from a tagged")
|
||||||
|
_, _ = fmt.Fprintln(w,
|
||||||
|
"commit, so it carries no release version. Released binaries")
|
||||||
|
_, _ = fmt.Fprintf(w,
|
||||||
|
"are published at %s\n", globals.ReleasesURL)
|
||||||
|
_, _ = fmt.Fprintln(w,
|
||||||
|
"and report their tag on the first line above.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/cli"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runVersionCommand executes `vaultik version` with its output
|
||||||
|
// captured, and returns what it printed.
|
||||||
|
func runVersionCommand(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := cli.NewVersionCommand()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("version command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersionCommandReportsBuildVersion checks that the first line of
|
||||||
|
// the report is the version the binary was actually built with. The
|
||||||
|
// test binary carries no -ldflags, so that is the "dev" default -- the
|
||||||
|
// same string an untagged `make vaultik` build stamps a prefix of.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||||
|
func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
||||||
|
out := runVersionCommand(t)
|
||||||
|
|
||||||
|
wantFirst := "vaultik " + globals.Version
|
||||||
|
if first, _, _ := strings.Cut(out, "\n"); first != wantFirst {
|
||||||
|
t.Errorf("first line = %q, want %q", first, wantFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(out, "commit:") {
|
||||||
|
t.Error("output does not report the commit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
|
||||||
|
// the thing this command exists to prevent: a build that is not a
|
||||||
|
// release must say so. The notice used to be gated on the version
|
||||||
|
// being exactly "dev", so once untagged builds started carrying their
|
||||||
|
// commit sha it would have gone silent and an unreleased binary would
|
||||||
|
// have looked like a release.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||||
|
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
|
||||||
|
if !globals.IsDevVersion(globals.Version) {
|
||||||
|
t.Skipf("test binary was stamped with release version %q",
|
||||||
|
globals.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := runVersionCommand(t)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "development build") {
|
||||||
|
t.Errorf("dev build did not print the development-build notice:\n%s",
|
||||||
|
out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(out, globals.ReleasesURL) {
|
||||||
|
t.Errorf("development-build notice does not point at %s:\n%s",
|
||||||
|
globals.ReleasesURL, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
+452
-63
@@ -1,32 +1,220 @@
|
|||||||
|
// Package config loads, validates, and provides the vaultik YAML
|
||||||
|
// configuration, including snapshot definitions, encryption recipients,
|
||||||
|
// and storage settings.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
"github.com/adrg/xdg"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application configuration
|
const appName = "vaultik"
|
||||||
type Config struct {
|
|
||||||
AgeRecipient string `yaml:"age_recipient"`
|
// secretKeyPrefix marks an age secret (private) key. It is compared
|
||||||
BackupInterval time.Duration `yaml:"backup_interval"`
|
// case-insensitively so a recipient entry that is actually a private key is
|
||||||
BlobSizeLimit int64 `yaml:"blob_size_limit"`
|
// caught and never passed to age or echoed back.
|
||||||
ChunkSize int64 `yaml:"chunk_size"`
|
//
|
||||||
Exclude []string `yaml:"exclude"`
|
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||||
FullScanInterval time.Duration `yaml:"full_scan_interval"`
|
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||||
Hostname string `yaml:"hostname"`
|
|
||||||
IndexPath string `yaml:"index_path"`
|
// Defaults and validation bounds for tunable settings. MB and GB are
|
||||||
IndexPrefix string `yaml:"index_prefix"`
|
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
||||||
MinTimeBetweenRun time.Duration `yaml:"min_time_between_run"`
|
// limits, which are 1024-based (MiB, GiB).
|
||||||
S3 S3Config `yaml:"s3"`
|
const (
|
||||||
SourceDirs []string `yaml:"source_dirs"`
|
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
||||||
CompressionLevel int `yaml:"compression_level"`
|
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
||||||
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||||
|
defaultCompressionLevel = 3
|
||||||
|
defaultPruneGracePeriod = 24 * time.Hour
|
||||||
|
minChunkSize = 1000 * 1000 // 1MB
|
||||||
|
minCompressionLevel = 1
|
||||||
|
maxCompressionLevel = 19
|
||||||
|
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||||
|
minS3PartSize = 5 * 1024 * 1024
|
||||||
|
maxS3PartSize = 5 * 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel validation errors.
|
||||||
|
var (
|
||||||
|
errNoConfigPath = errors.New("config path not provided")
|
||||||
|
errRecipientIsSecretKey = errors.New(
|
||||||
|
"an age secret key was given where a public key (age1...) belongs")
|
||||||
|
errRecipientNotX25519 = errors.New(
|
||||||
|
"not a valid recipient; only X25519 age1... public keys are supported")
|
||||||
|
errNoSnapshots = errors.New(
|
||||||
|
"at least one snapshot must be configured (see config.example.yml)")
|
||||||
|
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||||
|
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||||
|
errBlobSizeTooSmall = errors.New(
|
||||||
|
"blob_size_limit must be at least the largest chunk the chunker can " +
|
||||||
|
"emit (chunk_size times the FastCDC size spread)")
|
||||||
|
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||||
|
errBadS3PartSize = errors.New("s3.part_size must be between 5MiB and 5GiB")
|
||||||
|
errBadStorageScheme = errors.New(
|
||||||
|
"storage_url must start with s3://, file://, or rclone://")
|
||||||
|
errFileURLNotAbsolute = errors.New(
|
||||||
|
"a file:// storage_url needs an absolute path, " +
|
||||||
|
"as in file:///path/to/backup")
|
||||||
|
errNegativePruneGracePeriod = errors.New(
|
||||||
|
"prune_grace_period must not be negative")
|
||||||
|
errStorageNotConfigured = errors.New(
|
||||||
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||||
|
"s3.bucket + credentials")
|
||||||
|
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
|
||||||
|
errS3KeyIDRequired = errors.New("s3.access_key_id is required")
|
||||||
|
errS3SecretRequired = errors.New("s3.secret_access_key is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// expandTilde expands ~ at the start of a path to the user's home directory.
|
||||||
|
func expandTilde(path string) string {
|
||||||
|
if path == "~" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
return home
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3Config represents S3 storage configuration
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandTildeInURL expands ~ in file:// URLs.
|
||||||
|
func expandTildeInURL(url string) string {
|
||||||
|
if strings.HasPrefix(url, "file://~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
return "file://" + filepath.Join(home, url[9:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotConfig represents configuration for a named snapshot.
|
||||||
|
// Each snapshot backs up one or more paths and can have its own exclude patterns
|
||||||
|
// in addition to the global excludes.
|
||||||
|
type SnapshotConfig struct {
|
||||||
|
Paths []string `yaml:"paths"`
|
||||||
|
Exclude []string `yaml:"exclude"` // Additional excludes for this snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExcludes returns the combined exclude patterns for a named snapshot.
|
||||||
|
// It merges global excludes with the snapshot-specific excludes.
|
||||||
|
func (c *Config) GetExcludes(snapshotName string) []string {
|
||||||
|
snap, ok := c.Snapshots[snapshotName]
|
||||||
|
if !ok {
|
||||||
|
return c.Exclude
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snap.Exclude) == 0 {
|
||||||
|
return c.Exclude
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine global and snapshot-specific excludes
|
||||||
|
combined := make([]string, 0, len(c.Exclude)+len(snap.Exclude))
|
||||||
|
combined = append(combined, c.Exclude...)
|
||||||
|
combined = append(combined, snap.Exclude...)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotNames returns the names of all configured snapshots in sorted order.
|
||||||
|
func (c *Config) SnapshotNames() []string {
|
||||||
|
names := make([]string, 0, len(c.Snapshots))
|
||||||
|
for name := range c.Snapshots {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
// Sort for deterministic order
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names of the two places the age secret key can be configured, used by
|
||||||
|
// AgeSecretKeySourceName for error messages that must not echo the value.
|
||||||
|
//
|
||||||
|
//nolint:gosec // G101: these are the names of the config sources, not a key
|
||||||
|
const (
|
||||||
|
ageSecretKeySourceEnv = "VAULTIK_AGE_SECRET_KEY"
|
||||||
|
ageSecretKeySourceConfig = "age_secret_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgeSecretKeySourceName returns the human name of where AgeSecretKey was
|
||||||
|
// configured. A Config built directly (as in tests) has no recorded
|
||||||
|
// source, so it reports the config-file field name.
|
||||||
|
func (c *Config) AgeSecretKeySourceName() string {
|
||||||
|
if c.AgeSecretKeySource != "" {
|
||||||
|
return c.AgeSecretKeySource
|
||||||
|
}
|
||||||
|
|
||||||
|
return ageSecretKeySourceConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config represents the application configuration for Vaultik.
|
||||||
|
// It defines all settings for backup operations, including source directories,
|
||||||
|
// encryption recipients, storage configuration, and performance tuning parameters.
|
||||||
|
// Configuration is typically loaded from a YAML file.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // snake_case is the established config-file format
|
||||||
|
type Config struct {
|
||||||
|
AgeRecipients []string `yaml:"age_recipients"`
|
||||||
|
AgeSecretKey string `yaml:"age_secret_key"`
|
||||||
|
// AgeSecretKeySource names where AgeSecretKey was configured
|
||||||
|
// ("VAULTIK_AGE_SECRET_KEY" or "age_secret_key") so a later parse
|
||||||
|
// failure can name the source without echoing the secret value. It is
|
||||||
|
// set by Load and never read from or written to the config file.
|
||||||
|
AgeSecretKeySource string `yaml:"-"`
|
||||||
|
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||||
|
ChunkSize Size `yaml:"chunk_size"`
|
||||||
|
// Exclude holds global excludes applied to all snapshots.
|
||||||
|
Exclude []string `yaml:"exclude"`
|
||||||
|
Hostname string `yaml:"hostname"`
|
||||||
|
IndexPath string `yaml:"index_path"`
|
||||||
|
S3 S3Config `yaml:"s3"`
|
||||||
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
||||||
|
CompressionLevel int `yaml:"compression_level"`
|
||||||
|
|
||||||
|
// PruneGracePeriod is how old a blob no manifest lists must be before
|
||||||
|
// a prune deletes it, and how old every object in a directory under
|
||||||
|
// metadata/ with no manifest must be before a prune deletes that
|
||||||
|
// directory. A backup writes its manifest only once it has uploaded
|
||||||
|
// all its blobs and its db.zst.age, so a younger blob or directory may
|
||||||
|
// belong to a backup still running, on this host or another sharing
|
||||||
|
// the destination store.
|
||||||
|
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
|
||||||
|
|
||||||
|
// StorageURL specifies the storage backend using a URL format.
|
||||||
|
// Takes precedence over S3Config if set.
|
||||||
|
// Supported formats:
|
||||||
|
// - s3://bucket/prefix?endpoint=host®ion=us-east-1
|
||||||
|
// - file:///path/to/backup
|
||||||
|
// For S3 URLs, credentials are still read from s3.access_key_id
|
||||||
|
// and s3.secret_access_key.
|
||||||
|
StorageURL string `yaml:"storage_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Config represents S3 storage configuration for backup storage.
|
||||||
|
// It supports both AWS S3 and S3-compatible storage services.
|
||||||
|
// All fields except UseSSL and PartSize are required.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // snake_case is the established config-file format
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
Bucket string `yaml:"bucket"`
|
Bucket string `yaml:"bucket"`
|
||||||
@@ -34,17 +222,23 @@ type S3Config struct {
|
|||||||
AccessKeyID string `yaml:"access_key_id"`
|
AccessKeyID string `yaml:"access_key_id"`
|
||||||
SecretAccessKey string `yaml:"secret_access_key"`
|
SecretAccessKey string `yaml:"secret_access_key"`
|
||||||
Region string `yaml:"region"`
|
Region string `yaml:"region"`
|
||||||
UseSSL bool `yaml:"use_ssl"`
|
// UseSSL selects HTTPS for a scheme-less endpoint. Omitted (nil) means
|
||||||
PartSize int64 `yaml:"part_size"`
|
// the default, TLS; set it to false only to force plain HTTP.
|
||||||
|
UseSSL *bool `yaml:"use_ssl"`
|
||||||
|
PartSize Size `yaml:"part_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigPath wraps the config file path for fx injection
|
// Path wraps the config file path for fx dependency injection.
|
||||||
type ConfigPath string
|
// This type allows the config file path to be injected as a distinct type
|
||||||
|
// rather than a plain string, avoiding conflicts with other string dependencies.
|
||||||
|
type Path string
|
||||||
|
|
||||||
// New creates a new Config instance
|
// New creates a new Config instance by loading from the specified path.
|
||||||
func New(path ConfigPath) (*Config, error) {
|
// This function is used by the fx dependency injection framework.
|
||||||
|
// Returns an error if the path is empty or if loading fails.
|
||||||
|
func New(path Path) (*Config, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, fmt.Errorf("config path not provided")
|
return nil, errNoConfigPath
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := Load(string(path))
|
cfg, err := Load(string(path))
|
||||||
@@ -55,40 +249,102 @@ func New(path ConfigPath) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the configuration file
|
// Load reads the configuration file at path, parses it with Parse, warns
|
||||||
|
// when others can read the file, and validates the configuration.
|
||||||
|
// Returns an error if the file cannot be read, parsed, or if validation
|
||||||
|
// fails.
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config file permissions (warn if world or group readable)
|
||||||
|
//nolint:gosec // G703: config path is operator-supplied by design
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr == nil {
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode&0044 != 0 { // group or world readable
|
||||||
|
log.Warn(cfg.readableByOthersWarning(),
|
||||||
|
"path", path,
|
||||||
|
"mode", fmt.Sprintf("%04o", mode),
|
||||||
|
"recommendation", "chmod 600 "+path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads YAML config data as Load does, without validating it. It
|
||||||
|
// applies default values for optional fields, fills in ${...} references
|
||||||
|
// using smartconfig, and expands ~ in paths.
|
||||||
|
func Parse(data []byte) (*Config, error) {
|
||||||
|
// Load config using smartconfig for interpolation
|
||||||
|
sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
// Set defaults
|
// Set defaults
|
||||||
BlobSizeLimit: 10 * 1024 * 1024 * 1024, // 10GB
|
BlobSizeLimit: defaultBlobSizeLimit,
|
||||||
ChunkSize: 10 * 1024 * 1024, // 10MB
|
ChunkSize: defaultChunkSize,
|
||||||
BackupInterval: 1 * time.Hour,
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||||
FullScanInterval: 24 * time.Hour,
|
CompressionLevel: defaultCompressionLevel,
|
||||||
MinTimeBetweenRun: 15 * time.Minute,
|
PruneGracePeriod: defaultPruneGracePeriod,
|
||||||
IndexPath: "/var/lib/vaultik/index.sqlite",
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
IndexPrefix: "index/",
|
|
||||||
CompressionLevel: 3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
// Convert smartconfig data to YAML then unmarshal
|
||||||
|
configData := sc.Data()
|
||||||
|
|
||||||
|
yamlBytes, err := yaml.Marshal(configData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal config data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = yaml.Unmarshal(yamlBytes, cfg)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand tilde in all path fields
|
||||||
|
cfg.IndexPath = expandTilde(cfg.IndexPath)
|
||||||
|
cfg.StorageURL = expandTildeInURL(cfg.StorageURL)
|
||||||
|
|
||||||
|
// Expand tildes in snapshot paths
|
||||||
|
for name, snap := range cfg.Snapshots {
|
||||||
|
for i, path := range snap.Paths {
|
||||||
|
snap.Paths[i] = expandTilde(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Snapshots[name] = snap
|
||||||
|
}
|
||||||
|
|
||||||
// Check for environment variable override for IndexPath
|
// Check for environment variable override for IndexPath
|
||||||
if envIndexPath := os.Getenv("VAULTIK_INDEX_PATH"); envIndexPath != "" {
|
if envIndexPath := os.Getenv("VAULTIK_INDEX_PATH"); envIndexPath != "" {
|
||||||
cfg.IndexPath = envIndexPath
|
cfg.IndexPath = expandTilde(envIndexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.setAgeSecretKey()
|
||||||
|
|
||||||
// Get hostname if not set
|
// Get hostname if not set
|
||||||
if cfg.Hostname == "" {
|
if cfg.Hostname == "" {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get hostname: %w", err)
|
return nil, fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Hostname = hostname
|
cfg.Hostname = hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,59 +352,192 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.S3.Region == "" {
|
if cfg.S3.Region == "" {
|
||||||
cfg.S3.Region = "us-east-1"
|
cfg.S3.Region = "us-east-1"
|
||||||
}
|
}
|
||||||
if cfg.S3.PartSize == 0 {
|
|
||||||
cfg.S3.PartSize = 5 * 1024 * 1024 // 5MB
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the configuration is valid
|
// Validate checks if the configuration is valid and complete.
|
||||||
|
// It ensures all required fields are present and have valid values:
|
||||||
|
// - Every age recipient must parse as an X25519 age1... public key (so a
|
||||||
|
// bad entry fails at load, not mid-backup); errors name the position,
|
||||||
|
// never the value. An empty list is accepted, because only snapshot
|
||||||
|
// create needs a recipient and it checks for one itself
|
||||||
|
// - At least one snapshot must be configured with at least one path
|
||||||
|
// - Storage must be configured (either storage_url or s3.* fields)
|
||||||
|
// - Chunk size must be at least 1MB
|
||||||
|
// - Blob size limit must be at least the largest chunk the chunker can emit
|
||||||
|
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
||||||
|
// exceeds the configured limit
|
||||||
|
// - Compression level must be between 1 and 19
|
||||||
|
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
||||||
|
// - Prune grace period must not be negative
|
||||||
|
//
|
||||||
|
// Returns an error describing the first validation failure encountered.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
if c.AgeRecipient == "" {
|
for i, recipient := range c.AgeRecipients {
|
||||||
return fmt.Errorf("age_recipient is required")
|
err := validateAgeRecipient(recipient)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("age_recipients[%d]: %w", i, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.SourceDirs) == 0 {
|
if len(c.Snapshots) == 0 {
|
||||||
return fmt.Errorf("at least one source directory is required")
|
return errNoSnapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.S3.Endpoint == "" {
|
for name, snap := range c.Snapshots {
|
||||||
return fmt.Errorf("s3.endpoint is required")
|
if len(snap.Paths) == 0 {
|
||||||
|
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.S3.Bucket == "" {
|
// Validate storage configuration
|
||||||
return fmt.Errorf("s3.bucket is required")
|
err := c.validateStorage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.S3.AccessKeyID == "" {
|
if c.ChunkSize.Int64() < minChunkSize {
|
||||||
return fmt.Errorf("s3.access_key_id is required")
|
return errChunkSizeTooSmall
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.S3.SecretAccessKey == "" {
|
// The chunker can emit chunks up to chunk_size * ChunkSizeSpread, and the
|
||||||
return fmt.Errorf("s3.secret_access_key is required")
|
// packer places a single such chunk into an otherwise empty blob. A limit
|
||||||
|
// below that bound would let a blob exceed it, so reject it.
|
||||||
|
largestChunk := c.ChunkSize.Int64() * chunker.ChunkSizeSpread
|
||||||
|
if c.BlobSizeLimit.Int64() < largestChunk {
|
||||||
|
return fmt.Errorf("%w: need at least %d bytes",
|
||||||
|
errBlobSizeTooSmall, largestChunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.ChunkSize < 1024*1024 { // 1MB minimum
|
if c.CompressionLevel < minCompressionLevel ||
|
||||||
return fmt.Errorf("chunk_size must be at least 1MB")
|
c.CompressionLevel > maxCompressionLevel {
|
||||||
|
return errBadCompression
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.BlobSizeLimit < c.ChunkSize {
|
if c.S3.PartSize.Int64() < minS3PartSize ||
|
||||||
return fmt.Errorf("blob_size_limit must be at least chunk_size")
|
c.S3.PartSize.Int64() > maxS3PartSize {
|
||||||
|
return errBadS3PartSize
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CompressionLevel < 1 || c.CompressionLevel > 19 {
|
if c.PruneGracePeriod < 0 {
|
||||||
return fmt.Errorf("compression_level must be between 1 and 19")
|
return errNegativePruneGracePeriod
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module exports the config module for fx
|
// validateAgeRecipient parses one age_recipients entry with the age library
|
||||||
|
// and returns a value-free error on failure. A recipient string can be
|
||||||
|
// sensitive (an operator may paste a secret key by mistake), so neither the
|
||||||
|
// entry nor age's own error (which quotes its input) is ever included.
|
||||||
|
func validateAgeRecipient(recipient string) error {
|
||||||
|
if strings.HasPrefix(strings.ToUpper(recipient), secretKeyPrefix) {
|
||||||
|
return errRecipientIsSecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := age.ParseX25519Recipient(recipient)
|
||||||
|
if err != nil {
|
||||||
|
return errRecipientNotX25519
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAgeSecretKey records the age secret key and where it came from. The
|
||||||
|
// value is stored raw and parsed only where decryption happens
|
||||||
|
// (internal/vaultik), so backup, list and prune keep working whatever the
|
||||||
|
// field holds. The environment variable overrides the config-file field.
|
||||||
|
func (c *Config) setAgeSecretKey() {
|
||||||
|
if c.AgeSecretKey != "" {
|
||||||
|
c.AgeSecretKeySource = ageSecretKeySourceConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := os.Getenv("VAULTIK_AGE_SECRET_KEY"); env != "" {
|
||||||
|
c.AgeSecretKey = env
|
||||||
|
c.AgeSecretKeySource = ageSecretKeySourceEnv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readableByOthersWarning is the warning Load logs when others can read
|
||||||
|
// the config file. It says "may contain" because the S3 credentials are
|
||||||
|
// seen only after smartconfig has replaced any ${...} reference in the
|
||||||
|
// file with its value, so a set credential need not be in the file.
|
||||||
|
func (c *Config) readableByOthersWarning() string {
|
||||||
|
if c.S3.AccessKeyID != "" || c.S3.SecretAccessKey != "" {
|
||||||
|
return "Config file is readable by others and may contain S3 credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Config file is readable by others"
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateStorage validates storage configuration.
|
||||||
|
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||||
|
// File URLs don't require any S3 configuration.
|
||||||
|
// If StorageURL is not set, legacy S3 configuration is required.
|
||||||
|
func (c *Config) validateStorage() error {
|
||||||
|
if c.StorageURL != "" {
|
||||||
|
return c.validateStorageURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy S3 configuration
|
||||||
|
if c.S3.Endpoint == "" {
|
||||||
|
return errStorageNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.S3.Bucket == "" {
|
||||||
|
return errS3BucketRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.S3.AccessKeyID == "" {
|
||||||
|
return errS3KeyIDRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.S3.SecretAccessKey == "" {
|
||||||
|
return errS3SecretRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateStorageURL validates URL-based storage configuration. File and
|
||||||
|
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
||||||
|
// credential fields. A file URL must name an absolute path: a relative one
|
||||||
|
// would resolve against the working directory, so the destination would
|
||||||
|
// move with it.
|
||||||
|
func (c *Config) validateStorageURL() error {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(c.StorageURL, "file://"):
|
||||||
|
// storage.ParseStorageURL applies the same rule, but config
|
||||||
|
// cannot import storage, which imports config.
|
||||||
|
if !strings.HasPrefix(c.StorageURL, "file:///") {
|
||||||
|
return errFileURLNotAbsolute
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
||||||
|
// Rclone storage uses rclone's own config
|
||||||
|
return nil
|
||||||
|
case strings.HasPrefix(c.StorageURL, "s3://"):
|
||||||
|
// S3 storage needs credentials
|
||||||
|
if c.S3.AccessKeyID == "" {
|
||||||
|
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.S3.SecretAccessKey == "" {
|
||||||
|
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return errBadStorageScheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module exports the config module for fx dependency injection.
|
||||||
|
// It provides the Config type to other modules in the application.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // fx module definitions are package globals
|
||||||
var Module = fx.Module("config",
|
var Module = fx.Module("config",
|
||||||
fx.Provide(New),
|
fx.Provide(New),
|
||||||
)
|
)
|
||||||
+702
-12
@@ -1,15 +1,32 @@
|
|||||||
package config
|
package config //nolint:testpackage // exercises unexported source constants
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testSneakAgePublicKey = "age1278m9q7dp3chsh2dcy82qk27v047zywyvt" +
|
||||||
|
"xwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
testIntegrationAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu" +
|
||||||
|
"0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||||
|
testIntegrationAgePrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
||||||
|
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// Set up test environment
|
// Set up test environment
|
||||||
testConfigPath := filepath.Join("..", "..", "test", "config.yaml")
|
testConfigPath := filepath.Join("..", "..", "test", "config.yaml")
|
||||||
if absPath, err := filepath.Abs(testConfigPath); err == nil {
|
|
||||||
|
absPath, err := filepath.Abs(testConfigPath)
|
||||||
|
if err == nil {
|
||||||
_ = os.Setenv("VAULTIK_CONFIG", absPath)
|
_ = os.Setenv("VAULTIK_CONFIG", absPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,8 +34,11 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigLoad ensures the config package can be imported and basic functionality works
|
// TestConfigLoad ensures the config package can be imported and basic
|
||||||
|
// functionality works.
|
||||||
func TestConfigLoad(t *testing.T) {
|
func TestConfigLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Use the test config file
|
// Use the test config file
|
||||||
configPath := os.Getenv("VAULTIK_CONFIG")
|
configPath := os.Getenv("VAULTIK_CONFIG")
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
@@ -32,20 +52,36 @@ func TestConfigLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if cfg.AgeRecipient != "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" {
|
if len(cfg.AgeRecipients) != 2 {
|
||||||
t.Errorf("Expected age recipient to be set, got '%s'", cfg.AgeRecipient)
|
t.Errorf("Expected 2 age recipients, got %d", len(cfg.AgeRecipients))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.SourceDirs) != 2 {
|
if cfg.AgeRecipients[0] != testSneakAgePublicKey {
|
||||||
t.Errorf("Expected 2 source dirs, got %d", len(cfg.SourceDirs))
|
t.Errorf("Expected first age recipient to be %s, got '%s'",
|
||||||
|
testSneakAgePublicKey, cfg.AgeRecipients[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.SourceDirs[0] != "/tmp/vaultik-test-source" {
|
if len(cfg.Snapshots) != 1 {
|
||||||
t.Errorf("Expected first source dir to be '/tmp/vaultik-test-source', got '%s'", cfg.SourceDirs[0])
|
t.Errorf("Expected 1 snapshot, got %d", len(cfg.Snapshots))
|
||||||
|
}
|
||||||
|
|
||||||
|
testSnap, ok := cfg.Snapshots["test"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected 'test' snapshot to exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(testSnap.Paths) != 2 {
|
||||||
|
t.Errorf("Expected 2 paths in test snapshot, got %d", len(testSnap.Paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
if testSnap.Paths[0] != "/tmp/vaultik-test-source" {
|
||||||
|
t.Errorf("Expected first path to be '/tmp/vaultik-test-source', got '%s'",
|
||||||
|
testSnap.Paths[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.S3.Bucket != "vaultik-test-bucket" {
|
if cfg.S3.Bucket != "vaultik-test-bucket" {
|
||||||
t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'", cfg.S3.Bucket)
|
t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'",
|
||||||
|
cfg.S3.Bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Hostname != "test-host" {
|
if cfg.Hostname != "test-host" {
|
||||||
@@ -53,15 +89,669 @@ func TestConfigLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||||
|
// config.example.yml carries only neutral placeholders (no real credentials,
|
||||||
|
// private addresses, or internal host names) and still parses.
|
||||||
|
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
examplePath := filepath.Join("..", "..", "config.example.yml")
|
||||||
|
|
||||||
|
cfg, err := Load(examplePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load config.example.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.StorageURL != "rclone://myremote/path/to/backups" {
|
||||||
|
t.Errorf("Expected neutral storage_url, got '%s'", cfg.StorageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G304: examplePath is a fixed in-repo path, not user input
|
||||||
|
raw, err := os.ReadFile(examplePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read config.example.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(raw)
|
||||||
|
|
||||||
|
wantSubstrings := []string{
|
||||||
|
"YOUR_ACCESS_KEY",
|
||||||
|
"YOUR_SECRET_KEY",
|
||||||
|
"endpoint: https://",
|
||||||
|
}
|
||||||
|
for _, want := range wantSubstrings {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("Expected config.example.yml to contain %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw "http://" scheme would mean a plaintext, likely private endpoint.
|
||||||
|
if strings.Contains(text, "http://") {
|
||||||
|
t.Error("config.example.yml should not contain an http:// endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfigFromEnv tests loading config path from environment variable
|
// TestConfigFromEnv tests loading config path from environment variable
|
||||||
func TestConfigFromEnv(t *testing.T) {
|
func TestConfigFromEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
configPath := os.Getenv("VAULTIK_CONFIG")
|
configPath := os.Getenv("VAULTIK_CONFIG")
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
t.Skip("VAULTIK_CONFIG not set")
|
t.Skip("VAULTIK_CONFIG not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the file exists
|
// Verify the file exists
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
//nolint:gosec // G703: test config path comes from the test environment
|
||||||
t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s", configPath)
|
_, err := os.Stat(configPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s",
|
||||||
|
configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateBlobSizeLimit checks the blob_size_limit boundary: it must be at
|
||||||
|
// least the largest chunk the chunker can emit (chunk_size times
|
||||||
|
// chunker.ChunkSizeSpread), because the packer places a single such chunk into
|
||||||
|
// an otherwise empty blob. A limit between chunk_size and that bound is rejected.
|
||||||
|
func TestValidateBlobSizeLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const chunkSize = Size(10 * 1024 * 1024) // 10MB
|
||||||
|
|
||||||
|
largestChunk := chunkSize.Int64() * chunker.ChunkSizeSpread
|
||||||
|
|
||||||
|
newConfig := func(blobLimit Size) *Config {
|
||||||
|
return &Config{
|
||||||
|
AgeRecipients: []string{testSneakAgePublicKey},
|
||||||
|
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||||
|
StorageURL: "file:///tmp/vaultik-test-store",
|
||||||
|
ChunkSize: chunkSize,
|
||||||
|
BlobSizeLimit: blobLimit,
|
||||||
|
CompressionLevel: 3,
|
||||||
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
blobLimit Size
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "at chunk_size but below largest chunk is rejected",
|
||||||
|
blobLimit: chunkSize,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "between chunk_size and largest chunk is rejected",
|
||||||
|
blobLimit: Size(chunkSize.Int64() * 2),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte below largest chunk is rejected",
|
||||||
|
blobLimit: Size(largestChunk - 1),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exactly at largest chunk is accepted",
|
||||||
|
blobLimit: Size(largestChunk),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "above largest chunk is accepted",
|
||||||
|
blobLimit: Size(largestChunk * 100),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := newConfig(tt.blobLimit).Validate()
|
||||||
|
if tt.wantErr {
|
||||||
|
if !errors.Is(err, errBlobSizeTooSmall) {
|
||||||
|
t.Fatalf("Validate() error = %v, want errBlobSizeTooSmall", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateS3PartSize checks that s3.part_size is held to the part sizes
|
||||||
|
// S3 accepts, 5MiB to 5GiB, by changing only the part size of the test
|
||||||
|
// config. "5MB" in the config file is 5,000,000 bytes, below the minimum.
|
||||||
|
func TestValidateS3PartSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base, err := Load(os.Getenv("VAULTIK_CONFIG"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
partSize Size
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "5MB is rejected",
|
||||||
|
partSize: 5_000_000,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte below 5MiB is rejected",
|
||||||
|
partSize: minS3PartSize - 1,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "5MiB is accepted",
|
||||||
|
partSize: minS3PartSize,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "5GiB is accepted",
|
||||||
|
partSize: maxS3PartSize,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte above 5GiB is rejected",
|
||||||
|
partSize: maxS3PartSize + 1,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := *base
|
||||||
|
cfg.S3.PartSize = tt.partSize
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if tt.wantErr {
|
||||||
|
if !errors.Is(err, errBadS3PartSize) {
|
||||||
|
t.Fatalf("Validate() error = %v, want errBadS3PartSize", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadS3PartSize checks that a config file without s3.part_size loads
|
||||||
|
// with the 5MiB default, and that an explicit 0 fails at load like any other
|
||||||
|
// part size S3 refuses.
|
||||||
|
func TestLoadS3PartSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutPartSize = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
writeConfig := func(t *testing.T, text string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(text), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("absent loads as 5MiB", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Load(writeConfig(t, withoutPartSize))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.S3.PartSize != defaultS3PartSize {
|
||||||
|
t.Errorf("s3.part_size = %d, want %d",
|
||||||
|
cfg.S3.PartSize, defaultS3PartSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("0 is rejected", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := Load(writeConfig(t, withoutPartSize+"s3:\n part_size: 0\n"))
|
||||||
|
if !errors.Is(err, errBadS3PartSize) {
|
||||||
|
t.Fatalf("Load() error = %v, want errBadS3PartSize", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeDefaults checks that the defaults are the documented
|
||||||
|
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
|
||||||
|
// (1000-based), so writing a default out in the config file changes nothing.
|
||||||
|
func TestDocumentedSizeDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ChunkSize != defaultChunkSize {
|
||||||
|
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
|
||||||
|
cfg.ChunkSize, defaultChunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
|
||||||
|
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
|
||||||
|
cfg.BlobSizeLimit, defaultBlobSizeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||||
|
// every size the README, config.example.yml and the config init template
|
||||||
|
// list as an example is accepted.
|
||||||
|
func TestDocumentedSizeExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutSizes = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"chunk_size: 1MB", nil},
|
||||||
|
{"chunk_size: 999999", errChunkSizeTooSmall},
|
||||||
|
{"chunk_size: 5M", nil},
|
||||||
|
{"chunk_size: 10M", nil},
|
||||||
|
{"chunk_size: 10MB", nil},
|
||||||
|
{"chunk_size: 64MiB", nil},
|
||||||
|
{"chunk_size: 1GB", nil},
|
||||||
|
// Four times the default chunk_size, the documented minimum.
|
||||||
|
{"blob_size_limit: 40MB", nil},
|
||||||
|
{"blob_size_limit: 500MB", nil},
|
||||||
|
{"blob_size_limit: 1GB", nil},
|
||||||
|
{"blob_size_limit: 1GiB", nil},
|
||||||
|
{"blob_size_limit: 10G", nil},
|
||||||
|
{"blob_size_limit: 10GB", nil},
|
||||||
|
{"s3: {part_size: 5MiB}", nil},
|
||||||
|
{"s3: {part_size: 10MB}", nil},
|
||||||
|
{"s3: {part_size: 16MiB}", nil},
|
||||||
|
{"s3: {part_size: 100MiB}", nil},
|
||||||
|
{"s3: {part_size: 5GiB}", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||||
|
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||||
|
// and directories under metadata/ with no manifest at once, and that 0s
|
||||||
|
// and the documented examples are accepted.
|
||||||
|
func TestValidatePruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"prune_grace_period: 0s", nil},
|
||||||
|
{"prune_grace_period: 90m", nil},
|
||||||
|
{"prune_grace_period: 36h", nil},
|
||||||
|
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPruneGracePeriod checks that a config file without
|
||||||
|
// prune_grace_period loads with the documented 24h. The value is written out
|
||||||
|
// here rather than taken from defaultPruneGracePeriod, so that a change of the
|
||||||
|
// default fails this test.
|
||||||
|
func TestLoadPruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PruneGracePeriod != 24*time.Hour {
|
||||||
|
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
|
// relative path fails at load. Accepted, it would resolve against the
|
||||||
|
// working directory, so a backup run from another directory would write
|
||||||
|
// to a different destination and still succeed.
|
||||||
|
func TestLoadRejectsRelativeFileURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const relativeConfig = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file://dest\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(relativeConfig), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load(path)
|
||||||
|
if !errors.Is(err, errFileURLNotAbsolute) {
|
||||||
|
t.Fatalf("Load() error = %v, want errFileURLNotAbsolute", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "absolute path") {
|
||||||
|
t.Errorf("error %q does not say to use an absolute path", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||||
|
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
||||||
|
// least of all a pasted secret key — is echoed in the error. An empty list
|
||||||
|
// loads, because only snapshot create needs a recipient.
|
||||||
|
func TestValidateAgeRecipients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
baseConfig := func(recipients []string) *Config {
|
||||||
|
return &Config{
|
||||||
|
AgeRecipients: recipients,
|
||||||
|
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||||
|
StorageURL: "file:///tmp/vaultik-test-store",
|
||||||
|
ChunkSize: Size(10 * 1024 * 1024),
|
||||||
|
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
recipients []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no recipients is accepted",
|
||||||
|
recipients: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "placeholder recipient is rejected",
|
||||||
|
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh-ed25519 recipient is rejected",
|
||||||
|
recipients: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplekeydata"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "truncated age1 string is rejected",
|
||||||
|
recipients: []string{"age1short"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "secret key passed as recipient is rejected",
|
||||||
|
recipients: []string{testIntegrationAgePrivateKey},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two valid recipients are accepted",
|
||||||
|
recipients: []string{testSneakAgePublicKey, testIntegrationAgePublicKey},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := baseConfig(tt.recipients).Validate()
|
||||||
|
if !tt.wantErr {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() returned nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The entry itself must never appear in the error, since a
|
||||||
|
// recipient string can be a secret key.
|
||||||
|
for _, recipient := range tt.recipients {
|
||||||
|
if strings.Contains(err.Error(), recipient) {
|
||||||
|
t.Fatalf("Validate() error echoed the recipient value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgeSecretKeySourceName checks the name reported for the configured
|
||||||
|
// age secret key: the recorded source when Load set one, and the
|
||||||
|
// config-file field name for a Config built directly (as in tests).
|
||||||
|
func TestAgeSecretKeySourceName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unset defaults to config field",
|
||||||
|
source: "",
|
||||||
|
want: ageSecretKeySourceConfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "environment source",
|
||||||
|
source: ageSecretKeySourceEnv,
|
||||||
|
want: ageSecretKeySourceEnv,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config-file source",
|
||||||
|
source: ageSecretKeySourceConfig,
|
||||||
|
want: ageSecretKeySourceConfig,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{AgeSecretKeySource: tt.source}
|
||||||
|
if got := cfg.AgeSecretKeySourceName(); got != tt.want {
|
||||||
|
t.Errorf("AgeSecretKeySourceName() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadReadableConfig writes configYAML to a file that others can read,
|
||||||
|
// loads it, and returns what the logger wrote to stderr meanwhile. The
|
||||||
|
// logger writes to the os.Stderr it finds when it is initialized, so
|
||||||
|
// os.Stderr is pointed at a file first. Not parallel-safe: os.Stderr and
|
||||||
|
// the logger are process-global.
|
||||||
|
func loadReadableConfig(t *testing.T, configYAML string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
stderrPath := filepath.Join(dir, "stderr")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(configYAML), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G302: the test needs a config file others can read
|
||||||
|
err = os.Chmod(configPath, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chmod config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrFile, err := os.Create(stderrPath) //nolint:gosec // G304: test temp path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating stderr file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := os.Stderr
|
||||||
|
os.Stderr = stderrFile
|
||||||
|
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
_, loadErr := Load(configPath)
|
||||||
|
|
||||||
|
os.Stderr = previous
|
||||||
|
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
_ = stderrFile.Close()
|
||||||
|
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading stderr file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(captured)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadWarnsReadableConfigWithoutS3Credentials checks that a config
|
||||||
|
// file others can read, holding no S3 credentials, is warned about
|
||||||
|
// without a claim that it holds them.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // loadReadableConfig replaces os.Stderr
|
||||||
|
func TestLoadWarnsReadableConfigWithoutS3Credentials(t *testing.T) {
|
||||||
|
stderr := loadReadableConfig(t, `
|
||||||
|
storage_url: file:///var/backups/vaultik
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- /home
|
||||||
|
`)
|
||||||
|
|
||||||
|
if !strings.Contains(stderr, "Config file is readable by others") {
|
||||||
|
t.Errorf("expected a warning that the file is readable by others, got %q",
|
||||||
|
stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(stderr, "S3 credentials") {
|
||||||
|
t.Errorf("warning names S3 credentials the file does not set: %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadWarnsReadableConfigWithS3Credentials checks that a config file
|
||||||
|
// others can read and that sets S3 credentials, as values or as ${ENV:...}
|
||||||
|
// references, is warned about as one that may contain them.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // loadReadableConfig replaces os.Stderr
|
||||||
|
func TestLoadWarnsReadableConfigWithS3Credentials(t *testing.T) {
|
||||||
|
t.Setenv("VAULTIK_TEST_ACCESS_KEY_ID", "test-access-key")
|
||||||
|
t.Setenv("VAULTIK_TEST_SECRET_ACCESS_KEY", "test-secret-key")
|
||||||
|
|
||||||
|
configs := map[string]string{
|
||||||
|
"values": `
|
||||||
|
storage_url: s3://bucket/prefix?endpoint=s3.example.com
|
||||||
|
s3:
|
||||||
|
access_key_id: test-access-key
|
||||||
|
secret_access_key: test-secret-key
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- /home
|
||||||
|
`,
|
||||||
|
"references": `
|
||||||
|
storage_url: s3://bucket/prefix?endpoint=s3.example.com
|
||||||
|
s3:
|
||||||
|
access_key_id: ${ENV:VAULTIK_TEST_ACCESS_KEY_ID}
|
||||||
|
secret_access_key: ${ENV:VAULTIK_TEST_SECRET_ACCESS_KEY}
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- /home
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, configYAML := range configs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
stderr := loadReadableConfig(t, configYAML)
|
||||||
|
|
||||||
|
if !strings.Contains(stderr,
|
||||||
|
"Config file is readable by others and may contain S3 credentials") {
|
||||||
|
t.Errorf("expected a warning naming the S3 credentials, got %q",
|
||||||
|
stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errSizeType = errors.New("size must be a number or string")
|
||||||
|
errSizeTooLarge = errors.New("size exceeds maximum supported value")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Size represents a byte size that can be specified in configuration files.
|
||||||
|
// It can unmarshal from both numeric values (interpreted as bytes) and
|
||||||
|
// human-readable strings like "10MB", "2.5GB", or "1TB".
|
||||||
|
type Size int64
|
||||||
|
|
||||||
|
// UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be
|
||||||
|
// parsed from YAML configuration files. It accepts both numeric values
|
||||||
|
// (interpreted as bytes) and string values with units (e.g., "10MB").
|
||||||
|
func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
|
// Try to unmarshal as int64 first
|
||||||
|
var intVal int64
|
||||||
|
|
||||||
|
err := unmarshal(&intVal)
|
||||||
|
if err == nil {
|
||||||
|
*s = Size(intVal)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unmarshal as string
|
||||||
|
var strVal string
|
||||||
|
|
||||||
|
err = unmarshal(&strVal)
|
||||||
|
if err != nil {
|
||||||
|
return errSizeType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the string using go-humanize
|
||||||
|
bytes, err := humanize.ParseBytes(strVal)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid size format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes > math.MaxInt64 {
|
||||||
|
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = Size(bytes)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int64 returns the size as int64 bytes.
|
||||||
|
// This is useful when the size needs to be passed to APIs that expect
|
||||||
|
// a numeric byte count.
|
||||||
|
func (s Size) Int64() int64 {
|
||||||
|
return int64(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the size as a human-readable string.
|
||||||
|
// For example, 1048576 bytes would be formatted as "1.0 MB".
|
||||||
|
// This implements the fmt.Stringer interface.
|
||||||
|
func (s Size) String() string {
|
||||||
|
//nolint:gosec // G115: sizes are non-negative by construction
|
||||||
|
return humanize.Bytes(uint64(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSize parses a size string into a Size value
|
||||||
|
func ParseSize(s string) (Size, error) {
|
||||||
|
bytes, err := humanize.ParseBytes(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid size format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes > math.MaxInt64 {
|
||||||
|
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Size(bytes), nil
|
||||||
|
}
|
||||||
@@ -3,28 +3,35 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BlobChunkRepository provides access to the blob_chunks table, which maps
|
||||||
|
// blobs to the chunks they contain (with offset and length).
|
||||||
type BlobChunkRepository struct {
|
type BlobChunkRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewBlobChunkRepository creates a BlobChunkRepository backed by db.
|
||||||
func NewBlobChunkRepository(db *DB) *BlobChunkRepository {
|
func NewBlobChunkRepository(db *DB) *BlobChunkRepository {
|
||||||
return &BlobChunkRepository{db: db}
|
return &BlobChunkRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BlobChunkRepository) Create(ctx context.Context, tx *sql.Tx, bc *BlobChunk) error {
|
// Create inserts a blob_chunks row, using tx when non-nil.
|
||||||
|
func (r *BlobChunkRepository) Create(
|
||||||
|
ctx context.Context, tx *sql.Tx, bc *BlobChunk,
|
||||||
|
) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO blob_chunks (blob_hash, chunk_hash, offset, length)
|
INSERT INTO blob_chunks (blob_id, chunk_hash, offset, length)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, bc.BlobHash, bc.ChunkHash, bc.Offset, bc.Length)
|
_, err = tx.ExecContext(ctx, query, bc.BlobID, bc.ChunkHash, bc.Offset, bc.Length)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, bc.BlobHash, bc.ChunkHash, bc.Offset, bc.Length)
|
_, err = r.db.ExecWithLog(ctx, query, bc.BlobID, bc.ChunkHash, bc.Offset, bc.Length)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,55 +41,150 @@ func (r *BlobChunkRepository) Create(ctx context.Context, tx *sql.Tx, bc *BlobCh
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BlobChunkRepository) GetByBlobHash(ctx context.Context, blobHash string) ([]*BlobChunk, error) {
|
// GetByBlobID returns all chunks contained in the given blob, ordered by
|
||||||
|
// their offset within the blob.
|
||||||
|
func (r *BlobChunkRepository) GetByBlobID(
|
||||||
|
ctx context.Context, blobID string,
|
||||||
|
) ([]*BlobChunk, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT blob_hash, chunk_hash, offset, length
|
SELECT blob_id, chunk_hash, offset, length
|
||||||
FROM blob_chunks
|
FROM blob_chunks
|
||||||
WHERE blob_hash = ?
|
WHERE blob_id = ?
|
||||||
ORDER BY offset
|
ORDER BY offset
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, blobHash)
|
rows, err := r.db.conn.QueryContext(ctx, query, blobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying blob chunks: %w", err)
|
return nil, fmt.Errorf("querying blob chunks: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var blobChunks []*BlobChunk
|
var blobChunks []*BlobChunk
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var bc BlobChunk
|
var bc BlobChunk
|
||||||
err := rows.Scan(&bc.BlobHash, &bc.ChunkHash, &bc.Offset, &bc.Length)
|
|
||||||
|
err := rows.Scan(&bc.BlobID, &bc.ChunkHash, &bc.Offset, &bc.Length)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning blob chunk: %w", err)
|
return nil, fmt.Errorf("scanning blob chunk: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blobChunks = append(blobChunks, &bc)
|
blobChunks = append(blobChunks, &bc)
|
||||||
}
|
}
|
||||||
|
|
||||||
return blobChunks, rows.Err()
|
return blobChunks, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BlobChunkRepository) GetByChunkHash(ctx context.Context, chunkHash string) (*BlobChunk, error) {
|
// GetByChunkHash returns one blob_chunks row containing the given chunk,
|
||||||
|
// or nil if the chunk is not packed in any blob.
|
||||||
|
func (r *BlobChunkRepository) GetByChunkHash(
|
||||||
|
ctx context.Context, chunkHash string,
|
||||||
|
) (*BlobChunk, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT blob_hash, chunk_hash, offset, length
|
SELECT blob_id, chunk_hash, offset, length
|
||||||
FROM blob_chunks
|
FROM blob_chunks
|
||||||
WHERE chunk_hash = ?
|
WHERE chunk_hash = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
LogSQL("GetByChunkHash", query, chunkHash)
|
||||||
|
|
||||||
var bc BlobChunk
|
var bc BlobChunk
|
||||||
|
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, chunkHash).Scan(
|
err := r.db.conn.QueryRowContext(ctx, query, chunkHash).Scan(
|
||||||
&bc.BlobHash,
|
&bc.BlobID,
|
||||||
&bc.ChunkHash,
|
&bc.ChunkHash,
|
||||||
&bc.Offset,
|
&bc.Offset,
|
||||||
&bc.Length,
|
&bc.Length,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
LogSQL("GetByChunkHash", "No rows found", chunkHash)
|
||||||
|
|
||||||
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
LogSQL("GetByChunkHash", "Error", chunkHash, err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("querying blob chunk: %w", err)
|
return nil, fmt.Errorf("querying blob chunk: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogSQL("GetByChunkHash", "Found blob", chunkHash, "blob", bc.BlobID)
|
||||||
|
|
||||||
return &bc, nil
|
return &bc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByChunkHashTx retrieves a blob chunk within a transaction
|
||||||
|
func (r *BlobChunkRepository) GetByChunkHashTx(
|
||||||
|
ctx context.Context, tx *sql.Tx, chunkHash string,
|
||||||
|
) (*BlobChunk, error) {
|
||||||
|
query := `
|
||||||
|
SELECT blob_id, chunk_hash, offset, length
|
||||||
|
FROM blob_chunks
|
||||||
|
WHERE chunk_hash = ?
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
LogSQL("GetByChunkHashTx", query, chunkHash)
|
||||||
|
|
||||||
|
var bc BlobChunk
|
||||||
|
|
||||||
|
err := tx.QueryRowContext(ctx, query, chunkHash).Scan(
|
||||||
|
&bc.BlobID,
|
||||||
|
&bc.ChunkHash,
|
||||||
|
&bc.Offset,
|
||||||
|
&bc.Length,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
LogSQL("GetByChunkHashTx", "No rows found", chunkHash)
|
||||||
|
|
||||||
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
LogSQL("GetByChunkHashTx", "Error", chunkHash, err)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("querying blob chunk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogSQL("GetByChunkHashTx", "Found blob", chunkHash, "blob", bc.BlobID)
|
||||||
|
|
||||||
|
return &bc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrphaned deletes blob_chunks entries where either the blob or the
|
||||||
|
// chunk no longer exists.
|
||||||
|
func (r *BlobChunkRepository) DeleteOrphaned(ctx context.Context) error {
|
||||||
|
// Delete blob_chunks where the blob doesn't exist
|
||||||
|
query1 := `
|
||||||
|
DELETE FROM blob_chunks
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM blobs
|
||||||
|
WHERE blobs.id = blob_chunks.blob_id
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.ExecWithLog(ctx, query1)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting blob_chunks with missing blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete blob_chunks where the chunk doesn't exist
|
||||||
|
query2 := `
|
||||||
|
DELETE FROM blob_chunks
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM chunks
|
||||||
|
WHERE chunks.chunk_hash = blob_chunks.chunk_hash
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting blob_chunks with missing chunks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,146 +1,262 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Chunk hashes used across the blob_chunks tests.
|
||||||
|
const (
|
||||||
|
chunk1Hash = "chunk1"
|
||||||
|
chunk2Hash = "chunk2"
|
||||||
|
chunk3Hash = "chunk3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustCreateChunks registers the given chunk hashes (1024 bytes each).
|
||||||
|
func mustCreateChunks(
|
||||||
|
t *testing.T,
|
||||||
|
repos *database.Repositories,
|
||||||
|
hashes ...types.ChunkHash,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, chunkHash := range hashes {
|
||||||
|
chunk := &database.Chunk{
|
||||||
|
ChunkHash: chunkHash,
|
||||||
|
Size: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Chunks.Create(ctx, nil, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCreateBlob creates a blob row with the given hash.
|
||||||
|
func mustCreateBlob(
|
||||||
|
t *testing.T,
|
||||||
|
repos *database.Repositories,
|
||||||
|
hash types.BlobHash,
|
||||||
|
) *database.Blob {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
blob := &database.Blob{
|
||||||
|
ID: types.NewBlobID(),
|
||||||
|
Hash: hash,
|
||||||
|
CreatedTS: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Blobs.Create(context.Background(), nil, blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create blob %s: %v", hash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
|
||||||
func TestBlobChunkRepository(t *testing.T) {
|
func TestBlobChunkRepository(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewBlobChunkRepository(db)
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
blob := mustCreateBlob(t, repos, "blob1-hash")
|
||||||
|
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash)
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
bc1 := &BlobChunk{
|
bc1 := &database.BlobChunk{
|
||||||
BlobHash: "blob1",
|
BlobID: blob.ID,
|
||||||
ChunkHash: "chunk1",
|
ChunkHash: types.ChunkHash(chunk1Hash),
|
||||||
Offset: 0,
|
Offset: 0,
|
||||||
Length: 1024,
|
Length: 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repo.Create(ctx, nil, bc1)
|
err := repos.BlobChunks.Create(ctx, nil, bc1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create blob chunk: %v", err)
|
t.Fatalf("failed to create blob chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add more chunks to the same blob
|
// Add more chunks to the same blob
|
||||||
bc2 := &BlobChunk{
|
bc2 := &database.BlobChunk{
|
||||||
BlobHash: "blob1",
|
BlobID: blob.ID,
|
||||||
ChunkHash: "chunk2",
|
ChunkHash: types.ChunkHash(chunk2Hash),
|
||||||
Offset: 1024,
|
Offset: 1024,
|
||||||
Length: 2048,
|
Length: 2048,
|
||||||
}
|
}
|
||||||
err = repo.Create(ctx, nil, bc2)
|
|
||||||
|
err = repos.BlobChunks.Create(ctx, nil, bc2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create second blob chunk: %v", err)
|
t.Fatalf("failed to create second blob chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bc3 := &BlobChunk{
|
bc3 := &database.BlobChunk{
|
||||||
BlobHash: "blob1",
|
BlobID: blob.ID,
|
||||||
ChunkHash: "chunk3",
|
ChunkHash: types.ChunkHash(chunk3Hash),
|
||||||
Offset: 3072,
|
Offset: 3072,
|
||||||
Length: 512,
|
Length: 512,
|
||||||
}
|
}
|
||||||
err = repo.Create(ctx, nil, bc3)
|
|
||||||
|
err = repos.BlobChunks.Create(ctx, nil, bc3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create third blob chunk: %v", err)
|
t.Fatalf("failed to create third blob chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByBlobHash
|
// Test GetByBlobID
|
||||||
chunks, err := repo.GetByBlobHash(ctx, "blob1")
|
blobChunks, err := repos.BlobChunks.GetByBlobID(ctx, blob.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get blob chunks: %v", err)
|
t.Fatalf("failed to get blob chunks: %v", err)
|
||||||
}
|
}
|
||||||
if len(chunks) != 3 {
|
|
||||||
t.Errorf("expected 3 chunks, got %d", len(chunks))
|
if len(blobChunks) != 3 {
|
||||||
|
t.Errorf("expected 3 chunks, got %d", len(blobChunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify order by offset
|
// Verify order by offset
|
||||||
expectedOffsets := []int64{0, 1024, 3072}
|
expectedOffsets := []int64{0, 1024, 3072}
|
||||||
for i, chunk := range chunks {
|
for i, bc := range blobChunks {
|
||||||
if chunk.Offset != expectedOffsets[i] {
|
if bc.Offset != expectedOffsets[i] {
|
||||||
t.Errorf("wrong chunk order: expected offset %d, got %d", expectedOffsets[i], chunk.Offset)
|
t.Errorf("wrong chunk order: expected offset %d, got %d",
|
||||||
|
expectedOffsets[i], bc.Offset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test duplicate insert (should fail due to primary key constraint)
|
||||||
|
err = repos.BlobChunks.Create(ctx, nil, bc1)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("duplicate blob_chunk insert should fail due to primary key constraint")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "UNIQUE") &&
|
||||||
|
!strings.Contains(err.Error(), "constraint") {
|
||||||
|
t.Fatalf("expected constraint error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobChunkRepositoryGetByChunkHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
blob := mustCreateBlob(t, repos, "blob-gbch-hash")
|
||||||
|
mustCreateChunks(t, repos, chunk2Hash)
|
||||||
|
|
||||||
|
bc2 := &database.BlobChunk{
|
||||||
|
BlobID: blob.ID,
|
||||||
|
ChunkHash: types.ChunkHash(chunk2Hash),
|
||||||
|
Offset: 1024,
|
||||||
|
Length: 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.BlobChunks.Create(ctx, nil, bc2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create blob chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test GetByChunkHash
|
// Test GetByChunkHash
|
||||||
bc, err := repo.GetByChunkHash(ctx, "chunk2")
|
bc, err := repos.BlobChunks.GetByChunkHash(ctx, chunk2Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get blob chunk by chunk hash: %v", err)
|
t.Fatalf("failed to get blob chunk by chunk hash: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bc == nil {
|
if bc == nil {
|
||||||
t.Fatal("expected blob chunk, got nil")
|
t.Fatal("expected blob chunk, got nil")
|
||||||
}
|
}
|
||||||
if bc.BlobHash != "blob1" {
|
|
||||||
t.Errorf("wrong blob hash: expected blob1, got %s", bc.BlobHash)
|
if bc.BlobID != blob.ID {
|
||||||
|
t.Errorf("wrong blob ID: expected %s, got %s", blob.ID, bc.BlobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bc.Offset != 1024 {
|
if bc.Offset != 1024 {
|
||||||
t.Errorf("wrong offset: expected 1024, got %d", bc.Offset)
|
t.Errorf("wrong offset: expected 1024, got %d", bc.Offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test non-existent chunk
|
// Test non-existent chunk
|
||||||
bc, err = repo.GetByChunkHash(ctx, "nonexistent")
|
bc, err = repos.BlobChunks.GetByChunkHash(ctx, "nonexistent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bc != nil {
|
if bc != nil {
|
||||||
t.Error("expected nil for non-existent chunk")
|
t.Error("expected nil for non-existent chunk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlobChunkRepositoryMultipleBlobs(t *testing.T) {
|
func TestBlobChunkRepositoryMultipleBlobs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewBlobChunkRepository(db)
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
blob1 := mustCreateBlob(t, repos, "blob1-hash")
|
||||||
|
blob2 := mustCreateBlob(t, repos, "blob2-hash")
|
||||||
|
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash)
|
||||||
|
|
||||||
// Create chunks across multiple blobs
|
// Create chunks across multiple blobs
|
||||||
// Some chunks are shared between blobs (deduplication scenario)
|
// Some chunks are shared between blobs (deduplication scenario)
|
||||||
blobChunks := []BlobChunk{
|
blobChunks := []database.BlobChunk{
|
||||||
{BlobHash: "blob1", ChunkHash: "chunk1", Offset: 0, Length: 1024},
|
{BlobID: blob1.ID, ChunkHash: chunk1Hash, Offset: 0, Length: 1024},
|
||||||
{BlobHash: "blob1", ChunkHash: "chunk2", Offset: 1024, Length: 1024},
|
{BlobID: blob1.ID, ChunkHash: chunk2Hash, Offset: 1024, Length: 1024},
|
||||||
{BlobHash: "blob2", ChunkHash: "chunk2", Offset: 0, Length: 1024}, // chunk2 is shared
|
// chunk2 is shared between the blobs
|
||||||
{BlobHash: "blob2", ChunkHash: "chunk3", Offset: 1024, Length: 1024},
|
{BlobID: blob2.ID, ChunkHash: chunk2Hash, Offset: 0, Length: 1024},
|
||||||
|
{BlobID: blob2.ID, ChunkHash: chunk3Hash, Offset: 1024, Length: 1024},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, bc := range blobChunks {
|
for _, bc := range blobChunks {
|
||||||
err := repo.Create(ctx, nil, &bc)
|
err := repos.BlobChunks.Create(ctx, nil, &bc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create blob chunk: %v", err)
|
t.Fatalf("failed to create blob chunk: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify blob1 chunks
|
// Verify blob1 chunks
|
||||||
chunks, err := repo.GetByBlobHash(ctx, "blob1")
|
chunks, err := repos.BlobChunks.GetByBlobID(ctx, blob1.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get blob1 chunks: %v", err)
|
t.Fatalf("failed to get blob1 chunks: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunks) != 2 {
|
if len(chunks) != 2 {
|
||||||
t.Errorf("expected 2 chunks for blob1, got %d", len(chunks))
|
t.Errorf("expected 2 chunks for blob1, got %d", len(chunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify blob2 chunks
|
// Verify blob2 chunks
|
||||||
chunks, err = repo.GetByBlobHash(ctx, "blob2")
|
chunks, err = repos.BlobChunks.GetByBlobID(ctx, blob2.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get blob2 chunks: %v", err)
|
t.Fatalf("failed to get blob2 chunks: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunks) != 2 {
|
if len(chunks) != 2 {
|
||||||
t.Errorf("expected 2 chunks for blob2, got %d", len(chunks))
|
t.Errorf("expected 2 chunks for blob2, got %d", len(chunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify shared chunk
|
// Verify shared chunk
|
||||||
bc, err := repo.GetByChunkHash(ctx, "chunk2")
|
bc, err := repos.BlobChunks.GetByChunkHash(ctx, chunk2Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get shared chunk: %v", err)
|
t.Fatalf("failed to get shared chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bc == nil {
|
if bc == nil {
|
||||||
t.Fatal("expected shared chunk, got nil")
|
t.Fatal("expected shared chunk, got nil")
|
||||||
}
|
}
|
||||||
// GetByChunkHash returns first match, should be blob1
|
// GetByChunkHash returns first match, should be blob1
|
||||||
if bc.BlobHash != "blob1" {
|
if bc.BlobID != blob1.ID {
|
||||||
t.Errorf("expected blob1 for shared chunk, got %s", bc.BlobHash)
|
t.Errorf("expected %s for shared chunk, got %s", blob1.ID, bc.BlobID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+226
-42
@@ -3,29 +3,51 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BlobRepository provides access to the blobs table, which tracks the
|
||||||
|
// packed, encrypted storage units uploaded to the destination.
|
||||||
type BlobRepository struct {
|
type BlobRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewBlobRepository creates a BlobRepository backed by db.
|
||||||
func NewBlobRepository(db *DB) *BlobRepository {
|
func NewBlobRepository(db *DB) *BlobRepository {
|
||||||
return &BlobRepository{db: db}
|
return &BlobRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create inserts a blob row, using tx when non-nil.
|
||||||
func (r *BlobRepository) Create(ctx context.Context, tx *sql.Tx, blob *Blob) error {
|
func (r *BlobRepository) Create(ctx context.Context, tx *sql.Tx, blob *Blob) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO blobs (blob_hash, created_ts)
|
INSERT INTO blobs (id, blob_hash, created_ts, finished_ts,
|
||||||
VALUES (?, ?)
|
uncompressed_size, compressed_size, uploaded_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
var finishedTS, uploadedTS *int64
|
||||||
|
|
||||||
|
if blob.FinishedTS != nil {
|
||||||
|
ts := blob.FinishedTS.Unix()
|
||||||
|
finishedTS = &ts
|
||||||
|
}
|
||||||
|
|
||||||
|
if blob.UploadedTS != nil {
|
||||||
|
ts := blob.UploadedTS.Unix()
|
||||||
|
uploadedTS = &ts
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, blob.BlobHash, blob.CreatedTS.Unix())
|
_, err = tx.ExecContext(ctx, query, blob.ID, blob.Hash, blob.CreatedTS.Unix(),
|
||||||
|
finishedTS, blob.UncompressedSize, blob.CompressedSize, uploadedTS)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, blob.BlobHash, blob.CreatedTS.Unix())
|
_, err = r.db.ExecWithLog(ctx, query, blob.ID, blob.Hash, blob.CreatedTS.Unix(),
|
||||||
|
finishedTS, blob.UncompressedSize, blob.CompressedSize, uploadedTS)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,62 +57,224 @@ func (r *BlobRepository) Create(ctx context.Context, tx *sql.Tx, blob *Blob) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByHash returns the blob with the given content hash, or nil if no
|
||||||
|
// such blob exists.
|
||||||
func (r *BlobRepository) GetByHash(ctx context.Context, hash string) (*Blob, error) {
|
func (r *BlobRepository) GetByHash(ctx context.Context, hash string) (*Blob, error) {
|
||||||
|
return r.getOne(ctx, "blob_hash", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a blob by its ID
|
||||||
|
func (r *BlobRepository) GetByID(ctx context.Context, id string) (*Blob, error) {
|
||||||
|
return r.getOne(ctx, "id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns every blob row keyed by blob ID. Useful at restore
|
||||||
|
// start to translate the per-chunk blob_id references in chunkToBlobMap
|
||||||
|
// into blob hashes without doing one GetByID query per chunk.
|
||||||
|
func (r *BlobRepository) GetAll(ctx context.Context) (map[string]*Blob, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT blob_hash, created_ts
|
SELECT id, blob_hash, created_ts, finished_ts,
|
||||||
|
uncompressed_size, compressed_size, uploaded_ts
|
||||||
FROM blobs
|
FROM blobs
|
||||||
WHERE blob_hash = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
var blob Blob
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
var createdTSUnix int64
|
|
||||||
|
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, hash).Scan(
|
|
||||||
&blob.BlobHash,
|
|
||||||
&createdTSUnix,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying blob: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blob.CreatedTS = time.Unix(createdTSUnix, 0)
|
|
||||||
return &blob, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *BlobRepository) List(ctx context.Context, limit, offset int) ([]*Blob, error) {
|
|
||||||
query := `
|
|
||||||
SELECT blob_hash, created_ts
|
|
||||||
FROM blobs
|
|
||||||
ORDER BY blob_hash
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, limit, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying blobs: %w", err)
|
return nil, fmt.Errorf("querying blobs: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
|
||||||
var blobs []*Blob
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make(map[string]*Blob)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var blob Blob
|
var (
|
||||||
var createdTSUnix int64
|
blob Blob
|
||||||
|
createdTSUnix int64
|
||||||
|
finishedTSUnix, uploadedTSUnix sql.NullInt64
|
||||||
|
)
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&blob.BlobHash,
|
&blob.ID,
|
||||||
|
&blob.Hash,
|
||||||
&createdTSUnix,
|
&createdTSUnix,
|
||||||
|
&finishedTSUnix,
|
||||||
|
&blob.UncompressedSize,
|
||||||
|
&blob.CompressedSize,
|
||||||
|
&uploadedTSUnix,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning blob: %w", err)
|
return nil, fmt.Errorf("scanning blob: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blob.CreatedTS = time.Unix(createdTSUnix, 0)
|
blob.CreatedTS = time.Unix(createdTSUnix, 0).UTC()
|
||||||
blobs = append(blobs, &blob)
|
if finishedTSUnix.Valid {
|
||||||
|
ts := time.Unix(finishedTSUnix.Int64, 0).UTC()
|
||||||
|
blob.FinishedTS = &ts
|
||||||
}
|
}
|
||||||
|
|
||||||
return blobs, rows.Err()
|
if uploadedTSUnix.Valid {
|
||||||
|
ts := time.Unix(uploadedTSUnix.Int64, 0).UTC()
|
||||||
|
blob.UploadedTS = &ts
|
||||||
|
}
|
||||||
|
|
||||||
|
out[blob.ID.String()] = &blob
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFinished updates a blob when it's finalized
|
||||||
|
func (r *BlobRepository) UpdateFinished(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
id string,
|
||||||
|
hash string,
|
||||||
|
uncompressedSize, compressedSize int64,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
UPDATE blobs
|
||||||
|
SET blob_hash = ?, finished_ts = ?, uncompressed_size = ?, compressed_size = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, hash, now, uncompressedSize, compressedSize, id)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, hash, now, uncompressedSize, compressedSize, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating blob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUploaded marks a blob as uploaded and records the size of the
|
||||||
|
// stored object as its compressed size. That size can differ from the
|
||||||
|
// local copy's when an identical blob was already stored with another
|
||||||
|
// compression level or other recipients.
|
||||||
|
func (r *BlobRepository) UpdateUploaded(
|
||||||
|
ctx context.Context, tx *sql.Tx, id string, storedSize int64,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
UPDATE blobs
|
||||||
|
SET uploaded_ts = ?, compressed_size = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, now, storedSize, id)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, now, storedSize, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marking blob as uploaded: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrphaned deletes blobs that are not referenced by any snapshot
|
||||||
|
func (r *BlobRepository) DeleteOrphaned(ctx context.Context) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM blobs
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM snapshot_blobs
|
||||||
|
WHERE snapshot_blobs.blob_id = blobs.id
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.ExecWithLog(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting orphaned blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected > 0 {
|
||||||
|
log.Debug("Deleted orphaned blobs", "count", rowsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUnuploaded deletes blob rows whose upload never completed
|
||||||
|
// (uploaded_ts IS NULL) and returns how many were removed. Their
|
||||||
|
// blob_chunks rows are removed by the ON DELETE CASCADE foreign key.
|
||||||
|
// A blob is only ever attached to a snapshot once its upload has been
|
||||||
|
// recorded, so an un-uploaded blob is never referenced by a completed
|
||||||
|
// snapshot: dropping it discards chunk rows that point at data which
|
||||||
|
// was never stored remotely, so the affected content is re-chunked and
|
||||||
|
// re-uploaded on the next run.
|
||||||
|
func (r *BlobRepository) DeleteUnuploaded(ctx context.Context) (int64, error) {
|
||||||
|
query := `DELETE FROM blobs WHERE uploaded_ts IS NULL`
|
||||||
|
|
||||||
|
result, err := r.db.ExecWithLog(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("deleting un-uploaded blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected > 0 {
|
||||||
|
log.Debug("Deleted un-uploaded blobs", "count", rowsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOne fetches a single blob row matched on the given column, or
|
||||||
|
// (nil, nil) when no row matches.
|
||||||
|
func (r *BlobRepository) getOne(
|
||||||
|
ctx context.Context, column, value string,
|
||||||
|
) (*Blob, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, blob_hash, created_ts, finished_ts,
|
||||||
|
uncompressed_size, compressed_size, uploaded_ts
|
||||||
|
FROM blobs
|
||||||
|
WHERE ` + column + ` = ?`
|
||||||
|
|
||||||
|
var (
|
||||||
|
blob Blob
|
||||||
|
createdTSUnix int64
|
||||||
|
finishedTSUnix, uploadedTSUnix sql.NullInt64
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, value).Scan(
|
||||||
|
&blob.ID,
|
||||||
|
&blob.Hash,
|
||||||
|
&createdTSUnix,
|
||||||
|
&finishedTSUnix,
|
||||||
|
&blob.UncompressedSize,
|
||||||
|
&blob.CompressedSize,
|
||||||
|
&uploadedTSUnix,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying blob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blob.CreatedTS = time.Unix(createdTSUnix, 0).UTC()
|
||||||
|
if finishedTSUnix.Valid {
|
||||||
|
ts := time.Unix(finishedTSUnix.Int64, 0).UTC()
|
||||||
|
blob.FinishedTS = &ts
|
||||||
|
}
|
||||||
|
|
||||||
|
if uploadedTSUnix.Valid {
|
||||||
|
ts := time.Unix(uploadedTSUnix.Int64, 0).UTC()
|
||||||
|
blob.UploadedTS = &ts
|
||||||
|
}
|
||||||
|
|
||||||
|
return &blob, nil
|
||||||
}
|
}
|
||||||
+116
-41
@@ -1,21 +1,27 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBlobRepository(t *testing.T) {
|
func TestBlobRepository(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewBlobRepository(db)
|
repo := database.NewBlobRepository(db)
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
blob := &Blob{
|
blob := &database.Blob{
|
||||||
BlobHash: "blobhash123",
|
ID: types.NewBlobID(),
|
||||||
|
Hash: types.BlobHash("blobhash123"),
|
||||||
CreatedTS: time.Now().Truncate(time.Second),
|
CreatedTS: time.Now().Truncate(time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,65 +31,134 @@ func TestBlobRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByHash
|
// Test GetByHash
|
||||||
retrieved, err := repo.GetByHash(ctx, blob.BlobHash)
|
retrieved, err := repo.GetByHash(ctx, blob.Hash.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get blob: %v", err)
|
t.Fatalf("failed to get blob: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved == nil {
|
if retrieved == nil {
|
||||||
t.Fatal("expected blob, got nil")
|
t.Fatal("expected blob, got nil")
|
||||||
}
|
}
|
||||||
if retrieved.BlobHash != blob.BlobHash {
|
|
||||||
t.Errorf("blob hash mismatch: got %s, want %s", retrieved.BlobHash, blob.BlobHash)
|
if retrieved.Hash != blob.Hash {
|
||||||
}
|
t.Errorf("blob hash mismatch: got %s, want %s", retrieved.Hash, blob.Hash)
|
||||||
if !retrieved.CreatedTS.Equal(blob.CreatedTS) {
|
|
||||||
t.Errorf("created timestamp mismatch: got %v, want %v", retrieved.CreatedTS, blob.CreatedTS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test List
|
if !retrieved.CreatedTS.Equal(blob.CreatedTS) {
|
||||||
blob2 := &Blob{
|
t.Errorf("created timestamp mismatch: got %v, want %v",
|
||||||
BlobHash: "blobhash456",
|
retrieved.CreatedTS, blob.CreatedTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetByID
|
||||||
|
retrievedByID, err := repo.GetByID(ctx, blob.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get blob by ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedByID == nil {
|
||||||
|
t.Fatal("expected blob, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedByID.ID != blob.ID {
|
||||||
|
t.Errorf("blob ID mismatch: got %s, want %s", retrievedByID.ID, blob.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with second blob
|
||||||
|
blob2 := &database.Blob{
|
||||||
|
ID: types.NewBlobID(),
|
||||||
|
Hash: types.BlobHash("blobhash456"),
|
||||||
CreatedTS: time.Now().Truncate(time.Second),
|
CreatedTS: time.Now().Truncate(time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repo.Create(ctx, nil, blob2)
|
err = repo.Create(ctx, nil, blob2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create second blob: %v", err)
|
t.Fatalf("failed to create second blob: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blobs, err := repo.List(ctx, 10, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to list blobs: %v", err)
|
|
||||||
}
|
|
||||||
if len(blobs) != 2 {
|
|
||||||
t.Errorf("expected 2 blobs, got %d", len(blobs))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test pagination
|
func TestBlobRepositoryUpdates(t *testing.T) {
|
||||||
blobs, err = repo.List(ctx, 1, 0)
|
t.Parallel()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to list blobs with limit: %v", err)
|
|
||||||
}
|
|
||||||
if len(blobs) != 1 {
|
|
||||||
t.Errorf("expected 1 blob with limit, got %d", len(blobs))
|
|
||||||
}
|
|
||||||
|
|
||||||
blobs, err = repo.List(ctx, 1, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to list blobs with offset: %v", err)
|
|
||||||
}
|
|
||||||
if len(blobs) != 1 {
|
|
||||||
t.Errorf("expected 1 blob with offset, got %d", len(blobs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBlobRepositoryDuplicate(t *testing.T) {
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewBlobRepository(db)
|
repo := database.NewBlobRepository(db)
|
||||||
|
|
||||||
blob := &Blob{
|
blob := &database.Blob{
|
||||||
BlobHash: "duplicate_blob",
|
ID: types.NewBlobID(),
|
||||||
|
Hash: types.BlobHash("blobhash123"),
|
||||||
|
CreatedTS: time.Now().Truncate(time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Create(ctx, nil, blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create blob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test UpdateFinished
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
err = repo.UpdateFinished(ctx, nil, blob.ID.String(), blob.Hash.String(), 1000, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update blob as finished: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
updated, err := repo.GetByID(ctx, blob.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get updated blob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.FinishedTS == nil {
|
||||||
|
t.Fatal("expected finished timestamp to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.UncompressedSize != 1000 {
|
||||||
|
t.Errorf("expected uncompressed size 1000, got %d", updated.UncompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.CompressedSize != 500 {
|
||||||
|
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test UpdateUploaded, with a stored object larger than the local copy
|
||||||
|
err = repo.UpdateUploaded(ctx, nil, blob.ID.String(), 600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update blob as uploaded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify upload update
|
||||||
|
uploaded, err := repo.GetByID(ctx, blob.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get uploaded blob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uploaded.UploadedTS == nil {
|
||||||
|
t.Fatal("expected uploaded timestamp to be set")
|
||||||
|
}
|
||||||
|
// Allow 1 second tolerance for timestamp comparison
|
||||||
|
if uploaded.UploadedTS.Before(now.Add(-1 * time.Second)) {
|
||||||
|
t.Error("uploaded timestamp should be around test time")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uploaded.CompressedSize != 600 {
|
||||||
|
t.Errorf("expected the stored size 600, got %d", uploaded.CompressedSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobRepositoryDuplicate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewBlobRepository(db)
|
||||||
|
|
||||||
|
blob := &database.Blob{
|
||||||
|
ID: types.NewBlobID(),
|
||||||
|
Hash: types.BlobHash("duplicate_blob"),
|
||||||
CreatedTS: time.Now().Truncate(time.Second),
|
CreatedTS: time.Now().Truncate(time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//nolint:testpackage // inspects the unexported database connection
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createCascadeFixtures creates a file with three chunk mappings for the
|
||||||
|
// cascade-delete test.
|
||||||
|
func createCascadeFixtures(t *testing.T, repos *Repositories) *File {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
file := &File{
|
||||||
|
Path: "/cascade-test.txt",
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Files.Create(ctx, nil, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Created file with ID: %s", file.ID)
|
||||||
|
|
||||||
|
// Create chunks and file-chunk mappings
|
||||||
|
for i := range 3 {
|
||||||
|
chunk := &Chunk{
|
||||||
|
ChunkHash: types.ChunkHash(fmt.Sprintf("cascade-chunk-%d", i)),
|
||||||
|
Size: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repos.Chunks.Create(ctx, nil, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := &FileChunk{
|
||||||
|
FileID: file.ID,
|
||||||
|
Idx: i,
|
||||||
|
ChunkHash: chunk.ChunkHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repos.FileChunks.Create(ctx, nil, fc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Created file chunk mapping: file_id=%s, idx=%d, chunk=%s",
|
||||||
|
fc.FileID, fc.Idx, fc.ChunkHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
// logCascadeDebugInfo logs foreign-key state and the file_chunks table
|
||||||
|
// definition for cascade-delete debugging.
|
||||||
|
func logCascadeDebugInfo(ctx context.Context, t *testing.T, db *DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Check if foreign keys are enabled
|
||||||
|
var fkEnabled int
|
||||||
|
|
||||||
|
err := db.conn.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fkEnabled)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Foreign keys enabled: %d", fkEnabled)
|
||||||
|
|
||||||
|
// Check the foreign key constraint
|
||||||
|
var fkInfo string
|
||||||
|
|
||||||
|
err = db.conn.QueryRowContext(ctx, `
|
||||||
|
SELECT sql FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='file_chunks'
|
||||||
|
`).Scan(&fkInfo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("file_chunks table definition:\n%s", fkInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCascadeDeleteDebug tests cascade delete with debug output
|
||||||
|
func TestCascadeDeleteDebug(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := NewRepositories(db)
|
||||||
|
|
||||||
|
logCascadeDebugInfo(ctx, t, db)
|
||||||
|
|
||||||
|
file := createCascadeFixtures(t, repos)
|
||||||
|
|
||||||
|
// Verify file chunks exist
|
||||||
|
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("File chunks before delete: %d", len(fileChunks))
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
t.Log("Deleting file...")
|
||||||
|
|
||||||
|
err = repos.Files.DeleteByID(ctx, nil, file.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file is gone
|
||||||
|
deletedFile, err := repos.Files.GetByID(ctx, file.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deletedFile != nil {
|
||||||
|
t.Error("file should have been deleted")
|
||||||
|
} else {
|
||||||
|
t.Log("File was successfully deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file chunks after delete
|
||||||
|
fileChunks, err = repos.FileChunks.GetByFileID(ctx, file.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("File chunks after delete: %d", len(fileChunks))
|
||||||
|
|
||||||
|
// Manually check the database
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err = db.conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM file_chunks WHERE file_id = ?", file.ID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Manual count of file_chunks for deleted file: %d", count)
|
||||||
|
|
||||||
|
if len(fileChunks) != 0 {
|
||||||
|
t.Errorf("expected 0 file chunks after cascade delete, got %d", len(fileChunks))
|
||||||
|
// List the remaining chunks
|
||||||
|
for _, fc := range fileChunks {
|
||||||
|
t.Logf("Remaining chunk: file_id=%s, idx=%d, chunk=%s",
|
||||||
|
fc.FileID, fc.Idx, fc.ChunkHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,28 +4,39 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ChunkFileRepository provides access to the chunk_files table, the
|
||||||
|
// reverse mapping from chunks to the files that contain them.
|
||||||
type ChunkFileRepository struct {
|
type ChunkFileRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewChunkFileRepository creates a ChunkFileRepository backed by db.
|
||||||
func NewChunkFileRepository(db *DB) *ChunkFileRepository {
|
func NewChunkFileRepository(db *DB) *ChunkFileRepository {
|
||||||
return &ChunkFileRepository{db: db}
|
return &ChunkFileRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ChunkFileRepository) Create(ctx context.Context, tx *sql.Tx, cf *ChunkFile) error {
|
// Create inserts a chunk_files row (idempotently), using tx when non-nil.
|
||||||
|
func (r *ChunkFileRepository) Create(
|
||||||
|
ctx context.Context, tx *sql.Tx, cf *ChunkFile,
|
||||||
|
) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO chunk_files (chunk_hash, file_path, file_offset, length)
|
INSERT INTO chunk_files (chunk_hash, file_id, file_offset, length)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(chunk_hash, file_path) DO NOTHING
|
ON CONFLICT(chunk_hash, file_id) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, cf.ChunkHash, cf.FilePath, cf.FileOffset, cf.Length)
|
_, err = tx.ExecContext(ctx, query,
|
||||||
|
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, cf.ChunkHash, cf.FilePath, cf.FileOffset, cf.Length)
|
_, err = r.db.ExecWithLog(ctx, query,
|
||||||
|
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,52 +46,206 @@ func (r *ChunkFileRepository) Create(ctx context.Context, tx *sql.Tx, cf *ChunkF
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ChunkFileRepository) GetByChunkHash(ctx context.Context, chunkHash string) ([]*ChunkFile, error) {
|
// GetByChunkHash returns all chunk_files rows for the given chunk hash.
|
||||||
|
func (r *ChunkFileRepository) GetByChunkHash(
|
||||||
|
ctx context.Context, chunkHash types.ChunkHash,
|
||||||
|
) ([]*ChunkFile, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT chunk_hash, file_path, file_offset, length
|
SELECT chunk_hash, file_id, file_offset, length
|
||||||
FROM chunk_files
|
FROM chunk_files
|
||||||
WHERE chunk_hash = ?
|
WHERE chunk_hash = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, chunkHash)
|
rows, err := r.db.conn.QueryContext(ctx, query, chunkHash.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
|
||||||
var chunkFiles []*ChunkFile
|
defer func() { _ = rows.Close() }()
|
||||||
for rows.Next() {
|
|
||||||
var cf ChunkFile
|
return r.scanChunkFiles(rows)
|
||||||
err := rows.Scan(&cf.ChunkHash, &cf.FilePath, &cf.FileOffset, &cf.Length)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scanning chunk file: %w", err)
|
|
||||||
}
|
|
||||||
chunkFiles = append(chunkFiles, &cf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunkFiles, rows.Err()
|
// GetByFilePath returns all chunk_files rows for the file at the given path.
|
||||||
}
|
func (r *ChunkFileRepository) GetByFilePath(
|
||||||
|
ctx context.Context, filePath string,
|
||||||
func (r *ChunkFileRepository) GetByFilePath(ctx context.Context, filePath string) ([]*ChunkFile, error) {
|
) ([]*ChunkFile, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT chunk_hash, file_path, file_offset, length
|
SELECT cf.chunk_hash, cf.file_id, cf.file_offset, cf.length
|
||||||
FROM chunk_files
|
FROM chunk_files cf
|
||||||
WHERE file_path = ?
|
JOIN files f ON cf.file_id = f.id
|
||||||
|
WHERE f.path = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, filePath)
|
rows, err := r.db.conn.QueryContext(ctx, query, filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
return r.scanChunkFiles(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByFileID retrieves chunk files by file ID
|
||||||
|
func (r *ChunkFileRepository) GetByFileID(
|
||||||
|
ctx context.Context, fileID types.FileID,
|
||||||
|
) ([]*ChunkFile, error) {
|
||||||
|
query := `
|
||||||
|
SELECT chunk_hash, file_id, file_offset, length
|
||||||
|
FROM chunk_files
|
||||||
|
WHERE file_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, fileID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
return r.scanChunkFiles(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByFileID deletes all chunk_files entries for a given file ID
|
||||||
|
func (r *ChunkFileRepository) DeleteByFileID(
|
||||||
|
ctx context.Context, tx *sql.Tx, fileID types.FileID,
|
||||||
|
) error {
|
||||||
|
query := `DELETE FROM chunk_files WHERE file_id = ?`
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, fileID.String())
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, fileID.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting chunk files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByFileIDs deletes all chunk_files for multiple files in a single statement.
|
||||||
|
//
|
||||||
|
//nolint:dupl // symmetric implementation for a parallel association table
|
||||||
|
func (r *ChunkFileRepository) DeleteByFileIDs(
|
||||||
|
ctx context.Context, tx *sql.Tx, fileIDs []types.FileID,
|
||||||
|
) error {
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch at 500 to stay within SQLite's variable limit
|
||||||
|
const batchSize = 500
|
||||||
|
|
||||||
|
for i := 0; i < len(fileIDs); i += batchSize {
|
||||||
|
end := min(i+batchSize, len(fileIDs))
|
||||||
|
|
||||||
|
batch := fileIDs[i:end]
|
||||||
|
|
||||||
|
//nolint:gosec // G202: concatenates constant SQL and "?" placeholders only
|
||||||
|
query := "DELETE FROM chunk_files WHERE file_id IN (?" +
|
||||||
|
repeatPlaceholder(len(batch)-1) + ")"
|
||||||
|
|
||||||
|
args := make([]any, len(batch))
|
||||||
|
for j, id := range batch {
|
||||||
|
args[j] = id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, args...)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch deleting chunk_files: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBatch inserts multiple chunk_files in a single statement for efficiency.
|
||||||
|
func (r *ChunkFileRepository) CreateBatch(
|
||||||
|
ctx context.Context, tx *sql.Tx, cfs []ChunkFile,
|
||||||
|
) error {
|
||||||
|
if len(cfs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each chunk_files row binds this many SQL variables.
|
||||||
|
const chunkFileCols = 4
|
||||||
|
|
||||||
|
// Batch at 200 rows to be safe with SQLite's variable limit.
|
||||||
|
const batchSize = 200
|
||||||
|
|
||||||
|
for i := 0; i < len(cfs); i += batchSize {
|
||||||
|
end := min(i+batchSize, len(cfs))
|
||||||
|
|
||||||
|
batch := cfs[i:end]
|
||||||
|
|
||||||
|
query := "INSERT INTO chunk_files (chunk_hash, file_id, file_offset, length) VALUES "
|
||||||
|
|
||||||
|
args := make([]any, 0, len(batch)*chunkFileCols)
|
||||||
|
|
||||||
|
var querySb183 strings.Builder
|
||||||
|
|
||||||
|
for j, cf := range batch {
|
||||||
|
if j > 0 {
|
||||||
|
querySb183.WriteString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
querySb183.WriteString("(?, ?, ?, ?)")
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += querySb183.String()
|
||||||
|
|
||||||
|
query += " ON CONFLICT(chunk_hash, file_id) DO NOTHING"
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, args...)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch inserting chunk_files: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanChunkFiles is a helper that scans chunk file rows.
|
||||||
|
func (r *ChunkFileRepository) scanChunkFiles(rows *sql.Rows) ([]*ChunkFile, error) {
|
||||||
var chunkFiles []*ChunkFile
|
var chunkFiles []*ChunkFile
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cf ChunkFile
|
var (
|
||||||
err := rows.Scan(&cf.ChunkHash, &cf.FilePath, &cf.FileOffset, &cf.Length)
|
cf ChunkFile
|
||||||
|
chunkHashStr, fileIDStr string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(&chunkHashStr, &fileIDStr, &cf.FileOffset, &cf.Length)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning chunk file: %w", err)
|
return nil, fmt.Errorf("scanning chunk file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cf.ChunkHash = types.ChunkHash(chunkHashStr)
|
||||||
|
|
||||||
|
cf.FileID, err = types.ParseFileID(fileIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing file ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
chunkFiles = append(chunkFiles, &cf)
|
chunkFiles = append(chunkFiles, &cf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,93 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const chunk4Hash = "chunk4"
|
||||||
|
|
||||||
|
// verifyChunkFilePair asserts that the chunk-file rows cover both test
|
||||||
|
// files at their expected offsets.
|
||||||
|
func verifyChunkFilePair(
|
||||||
|
t *testing.T, chunkFiles []*database.ChunkFile,
|
||||||
|
file1ID, file2ID types.FileID,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
foundFile1 := false
|
||||||
|
foundFile2 := false
|
||||||
|
|
||||||
|
for _, cf := range chunkFiles {
|
||||||
|
if cf.FileID == file1ID && cf.FileOffset == 0 {
|
||||||
|
foundFile1 = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf.FileID == file2ID && cf.FileOffset == 2048 {
|
||||||
|
foundFile2 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundFile1 || !foundFile2 {
|
||||||
|
t.Error("not all expected files found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createChunkFileTestFiles creates the two files used by the chunk-file
|
||||||
|
// repository tests.
|
||||||
|
func createChunkFileTestFiles(
|
||||||
|
t *testing.T, fileRepo *database.FileRepository,
|
||||||
|
) (*database.File, *database.File) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
testTime := time.Now().Truncate(time.Second)
|
||||||
|
file1 := &database.File{
|
||||||
|
Path: testFilePath1,
|
||||||
|
MTime: testTime,
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
LinkTarget: "",
|
||||||
|
}
|
||||||
|
file2 := &database.File{
|
||||||
|
Path: testFilePath2,
|
||||||
|
MTime: testTime,
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
LinkTarget: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
mustCreateFile(t, fileRepo, file1)
|
||||||
|
mustCreateFile(t, fileRepo, file2)
|
||||||
|
|
||||||
|
return file1, file2
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkFileRepository(t *testing.T) {
|
func TestChunkFileRepository(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewChunkFileRepository(db)
|
repo := database.NewChunkFileRepository(db)
|
||||||
|
fileRepo := database.NewFileRepository(db)
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
file1, file2 := createChunkFileTestFiles(t, fileRepo)
|
||||||
|
mustCreateChunks(t, repos, chunk1Hash)
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
cf1 := &ChunkFile{
|
cf1 := &database.ChunkFile{
|
||||||
ChunkHash: "chunk1",
|
ChunkHash: types.ChunkHash(chunk1Hash),
|
||||||
FilePath: "/file1.txt",
|
FileID: file1.ID,
|
||||||
FileOffset: 0,
|
FileOffset: 0,
|
||||||
Length: 1024,
|
Length: 1024,
|
||||||
}
|
}
|
||||||
@@ -26,50 +98,42 @@ func TestChunkFileRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add same chunk in different file (deduplication scenario)
|
// Add same chunk in different file (deduplication scenario)
|
||||||
cf2 := &ChunkFile{
|
cf2 := &database.ChunkFile{
|
||||||
ChunkHash: "chunk1",
|
ChunkHash: types.ChunkHash(chunk1Hash),
|
||||||
FilePath: "/file2.txt",
|
FileID: file2.ID,
|
||||||
FileOffset: 2048,
|
FileOffset: 2048,
|
||||||
Length: 1024,
|
Length: 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repo.Create(ctx, nil, cf2)
|
err = repo.Create(ctx, nil, cf2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create second chunk file: %v", err)
|
t.Fatalf("failed to create second chunk file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByChunkHash
|
// Test GetByChunkHash
|
||||||
chunkFiles, err := repo.GetByChunkHash(ctx, "chunk1")
|
chunkFiles, err := repo.GetByChunkHash(ctx, chunk1Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get chunk files: %v", err)
|
t.Fatalf("failed to get chunk files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunkFiles) != 2 {
|
if len(chunkFiles) != 2 {
|
||||||
t.Errorf("expected 2 files for chunk, got %d", len(chunkFiles))
|
t.Errorf("expected 2 files for chunk, got %d", len(chunkFiles))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify both files are returned
|
// Verify both files are returned
|
||||||
foundFile1 := false
|
verifyChunkFilePair(t, chunkFiles, file1.ID, file2.ID)
|
||||||
foundFile2 := false
|
|
||||||
for _, cf := range chunkFiles {
|
// Test GetByFileID
|
||||||
if cf.FilePath == "/file1.txt" && cf.FileOffset == 0 {
|
chunkFiles, err = repo.GetByFileID(ctx, file1.ID)
|
||||||
foundFile1 = true
|
if err != nil {
|
||||||
}
|
t.Fatalf("failed to get chunks by file ID: %v", err)
|
||||||
if cf.FilePath == "/file2.txt" && cf.FileOffset == 2048 {
|
|
||||||
foundFile2 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundFile1 || !foundFile2 {
|
|
||||||
t.Error("not all expected files found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByFilePath
|
|
||||||
chunkFiles, err = repo.GetByFilePath(ctx, "/file1.txt")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get chunks by file path: %v", err)
|
|
||||||
}
|
|
||||||
if len(chunkFiles) != 1 {
|
if len(chunkFiles) != 1 {
|
||||||
t.Errorf("expected 1 chunk for file, got %d", len(chunkFiles))
|
t.Errorf("expected 1 chunk for file, got %d", len(chunkFiles))
|
||||||
}
|
}
|
||||||
if chunkFiles[0].ChunkHash != "chunk1" {
|
|
||||||
|
if chunkFiles[0].ChunkHash != types.ChunkHash(chunk1Hash) {
|
||||||
t.Errorf("wrong chunk hash: expected chunk1, got %s", chunkFiles[0].ChunkHash)
|
t.Errorf("wrong chunk hash: expected chunk1, got %s", chunkFiles[0].ChunkHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,29 +145,53 @@ func TestChunkFileRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestChunkFileRepositoryComplexDeduplication(t *testing.T) {
|
func TestChunkFileRepositoryComplexDeduplication(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewChunkFileRepository(db)
|
repo := database.NewChunkFileRepository(db)
|
||||||
|
fileRepo := database.NewFileRepository(db)
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testTime := time.Now().Truncate(time.Second)
|
||||||
|
file1 := &database.File{
|
||||||
|
Path: testFilePath1, MTime: testTime, Size: 3072,
|
||||||
|
Mode: 0644, UID: 1000, GID: 1000,
|
||||||
|
}
|
||||||
|
file2 := &database.File{
|
||||||
|
Path: testFilePath2, MTime: testTime, Size: 3072,
|
||||||
|
Mode: 0644, UID: 1000, GID: 1000,
|
||||||
|
}
|
||||||
|
file3 := &database.File{
|
||||||
|
Path: "/file3.txt", MTime: testTime, Size: 2048,
|
||||||
|
Mode: 0644, UID: 1000, GID: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
mustCreateFile(t, fileRepo, file1)
|
||||||
|
mustCreateFile(t, fileRepo, file2)
|
||||||
|
mustCreateFile(t, fileRepo, file3)
|
||||||
|
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash, chunk4Hash)
|
||||||
|
|
||||||
// Simulate a scenario where multiple files share chunks
|
// Simulate a scenario where multiple files share chunks
|
||||||
// File1: chunk1, chunk2, chunk3
|
// File1: chunk1, chunk2, chunk3
|
||||||
// File2: chunk2, chunk3, chunk4
|
// File2: chunk2, chunk3, chunk4
|
||||||
// File3: chunk1, chunk4
|
// File3: chunk1, chunk4
|
||||||
|
|
||||||
chunkFiles := []ChunkFile{
|
chunkFiles := []database.ChunkFile{
|
||||||
// File1
|
// File1
|
||||||
{ChunkHash: "chunk1", FilePath: "/file1.txt", FileOffset: 0, Length: 1024},
|
{ChunkHash: chunk1Hash, FileID: file1.ID, FileOffset: 0, Length: 1024},
|
||||||
{ChunkHash: "chunk2", FilePath: "/file1.txt", FileOffset: 1024, Length: 1024},
|
{ChunkHash: chunk2Hash, FileID: file1.ID, FileOffset: 1024, Length: 1024},
|
||||||
{ChunkHash: "chunk3", FilePath: "/file1.txt", FileOffset: 2048, Length: 1024},
|
{ChunkHash: chunk3Hash, FileID: file1.ID, FileOffset: 2048, Length: 1024},
|
||||||
// File2
|
// File2
|
||||||
{ChunkHash: "chunk2", FilePath: "/file2.txt", FileOffset: 0, Length: 1024},
|
{ChunkHash: chunk2Hash, FileID: file2.ID, FileOffset: 0, Length: 1024},
|
||||||
{ChunkHash: "chunk3", FilePath: "/file2.txt", FileOffset: 1024, Length: 1024},
|
{ChunkHash: chunk3Hash, FileID: file2.ID, FileOffset: 1024, Length: 1024},
|
||||||
{ChunkHash: "chunk4", FilePath: "/file2.txt", FileOffset: 2048, Length: 1024},
|
{ChunkHash: chunk4Hash, FileID: file2.ID, FileOffset: 2048, Length: 1024},
|
||||||
// File3
|
// File3
|
||||||
{ChunkHash: "chunk1", FilePath: "/file3.txt", FileOffset: 0, Length: 1024},
|
{ChunkHash: chunk1Hash, FileID: file3.ID, FileOffset: 0, Length: 1024},
|
||||||
{ChunkHash: "chunk4", FilePath: "/file3.txt", FileOffset: 1024, Length: 1024},
|
{ChunkHash: chunk4Hash, FileID: file3.ID, FileOffset: 1024, Length: 1024},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cf := range chunkFiles {
|
for _, cf := range chunkFiles {
|
||||||
@@ -114,29 +202,32 @@ func TestChunkFileRepositoryComplexDeduplication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test chunk1 (used by file1 and file3)
|
// Test chunk1 (used by file1 and file3)
|
||||||
files, err := repo.GetByChunkHash(ctx, "chunk1")
|
files, err := repo.GetByChunkHash(ctx, chunk1Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get files for chunk1: %v", err)
|
t.Fatalf("failed to get files for chunk1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) != 2 {
|
if len(files) != 2 {
|
||||||
t.Errorf("expected 2 files for chunk1, got %d", len(files))
|
t.Errorf("expected 2 files for chunk1, got %d", len(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test chunk2 (used by file1 and file2)
|
// Test chunk2 (used by file1 and file2)
|
||||||
files, err = repo.GetByChunkHash(ctx, "chunk2")
|
files, err = repo.GetByChunkHash(ctx, chunk2Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get files for chunk2: %v", err)
|
t.Fatalf("failed to get files for chunk2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) != 2 {
|
if len(files) != 2 {
|
||||||
t.Errorf("expected 2 files for chunk2, got %d", len(files))
|
t.Errorf("expected 2 files for chunk2, got %d", len(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test file2 chunks
|
// Test file2 chunks
|
||||||
chunks, err := repo.GetByFilePath(ctx, "/file2.txt")
|
file2Chunks, err := repo.GetByFileID(ctx, file2.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get chunks for file2: %v", err)
|
t.Fatalf("failed to get chunks for file2: %v", err)
|
||||||
}
|
}
|
||||||
if len(chunks) != 3 {
|
|
||||||
t.Errorf("expected 3 chunks for file2, got %d", len(chunks))
|
if len(file2Chunks) != 3 {
|
||||||
|
t.Errorf("expected 3 chunks for file2, got %d", len(file2Chunks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+74
-19
@@ -3,29 +3,37 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ChunkRepository provides access to the chunks table, which tracks
|
||||||
|
// content-defined chunks by hash and size.
|
||||||
type ChunkRepository struct {
|
type ChunkRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewChunkRepository creates a ChunkRepository backed by db.
|
||||||
func NewChunkRepository(db *DB) *ChunkRepository {
|
func NewChunkRepository(db *DB) *ChunkRepository {
|
||||||
return &ChunkRepository{db: db}
|
return &ChunkRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create inserts a chunk row (idempotently), using tx when non-nil.
|
||||||
func (r *ChunkRepository) Create(ctx context.Context, tx *sql.Tx, chunk *Chunk) error {
|
func (r *ChunkRepository) Create(ctx context.Context, tx *sql.Tx, chunk *Chunk) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO chunks (chunk_hash, sha256, size)
|
INSERT INTO chunks (chunk_hash, size)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(chunk_hash) DO NOTHING
|
ON CONFLICT(chunk_hash) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, chunk.ChunkHash, chunk.SHA256, chunk.Size)
|
_, err = tx.ExecContext(ctx, query, chunk.ChunkHash, chunk.Size)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, chunk.ChunkHash, chunk.SHA256, chunk.Size)
|
_, err = r.db.ExecWithLog(ctx, query, chunk.ChunkHash, chunk.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,9 +43,11 @@ func (r *ChunkRepository) Create(ctx context.Context, tx *sql.Tx, chunk *Chunk)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByHash returns the chunk with the given hash, or nil if it is not
|
||||||
|
// known to the index.
|
||||||
func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, error) {
|
func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT chunk_hash, sha256, size
|
SELECT chunk_hash, size
|
||||||
FROM chunks
|
FROM chunks
|
||||||
WHERE chunk_hash = ?
|
WHERE chunk_hash = ?
|
||||||
`
|
`
|
||||||
@@ -46,13 +56,13 @@ func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, e
|
|||||||
|
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, hash).Scan(
|
err := r.db.conn.QueryRowContext(ctx, query, hash).Scan(
|
||||||
&chunk.ChunkHash,
|
&chunk.ChunkHash,
|
||||||
&chunk.SHA256,
|
|
||||||
&chunk.Size,
|
&chunk.Size,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying chunk: %w", err)
|
return nil, fmt.Errorf("querying chunk: %w", err)
|
||||||
}
|
}
|
||||||
@@ -60,39 +70,52 @@ func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, e
|
|||||||
return &chunk, nil
|
return &chunk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ChunkRepository) GetByHashes(ctx context.Context, hashes []string) ([]*Chunk, error) {
|
// GetByHashes returns the chunks whose hashes appear in hashes, ordered by
|
||||||
|
// chunk hash. Unknown hashes are silently omitted from the result.
|
||||||
|
func (r *ChunkRepository) GetByHashes(
|
||||||
|
ctx context.Context, hashes []string,
|
||||||
|
) ([]*Chunk, error) {
|
||||||
if len(hashes) == 0 {
|
if len(hashes) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT chunk_hash, sha256, size
|
SELECT chunk_hash, size
|
||||||
FROM chunks
|
FROM chunks
|
||||||
WHERE chunk_hash IN (`
|
WHERE chunk_hash IN (`
|
||||||
|
|
||||||
args := make([]interface{}, len(hashes))
|
args := make([]any, len(hashes))
|
||||||
|
|
||||||
|
var querySb75 strings.Builder
|
||||||
|
|
||||||
for i, hash := range hashes {
|
for i, hash := range hashes {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
query += ", "
|
querySb75.WriteString(", ")
|
||||||
}
|
}
|
||||||
query += "?"
|
|
||||||
|
querySb75.WriteString("?")
|
||||||
|
|
||||||
args[i] = hash
|
args[i] = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query += querySb75.String()
|
||||||
|
|
||||||
query += ") ORDER BY chunk_hash"
|
query += ") ORDER BY chunk_hash"
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, args...)
|
rows, err := r.db.conn.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var chunks []*Chunk
|
var chunks []*Chunk
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var chunk Chunk
|
var chunk Chunk
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&chunk.ChunkHash,
|
&chunk.ChunkHash,
|
||||||
&chunk.SHA256,
|
|
||||||
&chunk.Size,
|
&chunk.Size,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,9 +128,13 @@ func (r *ChunkRepository) GetByHashes(ctx context.Context, hashes []string) ([]*
|
|||||||
return chunks, rows.Err()
|
return chunks, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ChunkRepository) ListUnpacked(ctx context.Context, limit int) ([]*Chunk, error) {
|
// ListUnpacked returns up to limit chunks that are not yet stored in any
|
||||||
|
// blob, ordered by chunk hash.
|
||||||
|
func (r *ChunkRepository) ListUnpacked(
|
||||||
|
ctx context.Context, limit int,
|
||||||
|
) ([]*Chunk, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT c.chunk_hash, c.sha256, c.size
|
SELECT c.chunk_hash, c.size
|
||||||
FROM chunks c
|
FROM chunks c
|
||||||
LEFT JOIN blob_chunks bc ON c.chunk_hash = bc.chunk_hash
|
LEFT JOIN blob_chunks bc ON c.chunk_hash = bc.chunk_hash
|
||||||
WHERE bc.chunk_hash IS NULL
|
WHERE bc.chunk_hash IS NULL
|
||||||
@@ -119,15 +146,16 @@ func (r *ChunkRepository) ListUnpacked(ctx context.Context, limit int) ([]*Chunk
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying unpacked chunks: %w", err)
|
return nil, fmt.Errorf("querying unpacked chunks: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var chunks []*Chunk
|
var chunks []*Chunk
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var chunk Chunk
|
var chunk Chunk
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&chunk.ChunkHash,
|
&chunk.ChunkHash,
|
||||||
&chunk.SHA256,
|
|
||||||
&chunk.Size,
|
&chunk.Size,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,3 +167,30 @@ func (r *ChunkRepository) ListUnpacked(ctx context.Context, limit int) ([]*Chunk
|
|||||||
|
|
||||||
return chunks, rows.Err()
|
return chunks, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteOrphaned deletes chunks that are not referenced by any file or blob
|
||||||
|
func (r *ChunkRepository) DeleteOrphaned(ctx context.Context) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM chunks
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM file_chunks
|
||||||
|
WHERE file_chunks.chunk_hash = chunks.chunk_hash
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM blob_chunks
|
||||||
|
WHERE blob_chunks.chunk_hash = chunks.chunk_hash
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.ExecWithLog(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting orphaned chunks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected > 0 {
|
||||||
|
log.Debug("Deleted orphaned chunks", "count", rowsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List returns every chunk in the index, ordered by chunk hash.
|
||||||
|
func (r *ChunkRepository) List(ctx context.Context) ([]*Chunk, error) {
|
||||||
|
return r.list(ctx, `
|
||||||
|
SELECT chunk_hash, size
|
||||||
|
FROM chunks
|
||||||
|
ORDER BY chunk_hash
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInUploadedBlobs returns the chunks that are stored in a blob whose
|
||||||
|
// upload has completed (uploaded_ts set), ordered by chunk hash. These
|
||||||
|
// are the only chunks a backup may safely deduplicate against: a chunk
|
||||||
|
// recorded solely in a blob that was never uploaded refers to data that
|
||||||
|
// is not in remote storage, so trusting it would silently drop that data
|
||||||
|
// from later snapshots.
|
||||||
|
func (r *ChunkRepository) ListInUploadedBlobs(ctx context.Context) ([]*Chunk, error) {
|
||||||
|
return r.list(ctx, `
|
||||||
|
SELECT DISTINCT c.chunk_hash, c.size
|
||||||
|
FROM chunks c
|
||||||
|
JOIN blob_chunks bc ON c.chunk_hash = bc.chunk_hash
|
||||||
|
JOIN blobs b ON bc.blob_id = b.id
|
||||||
|
WHERE b.uploaded_ts IS NOT NULL
|
||||||
|
ORDER BY c.chunk_hash
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// list runs a chunk-selecting query and scans the (chunk_hash, size) rows.
|
||||||
|
func (r *ChunkRepository) list(ctx context.Context, query string) ([]*Chunk, error) {
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var chunks []*Chunk
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var chunk Chunk
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&chunk.ChunkHash,
|
||||||
|
&chunk.Size,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning chunk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = append(chunks, &chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks, rows.Err()
|
||||||
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChunkRepository(t *testing.T) {
|
func TestChunkRepository(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewChunkRepository(db)
|
repo := database.NewChunkRepository(db)
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
chunk := &Chunk{
|
chunk := &database.Chunk{
|
||||||
ChunkHash: "chunkhash123",
|
ChunkHash: types.ChunkHash("chunkhash123"),
|
||||||
SHA256: "sha256hash123",
|
|
||||||
Size: 4096,
|
Size: 4096,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,19 +29,19 @@ func TestChunkRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByHash
|
// Test GetByHash
|
||||||
retrieved, err := repo.GetByHash(ctx, chunk.ChunkHash)
|
retrieved, err := repo.GetByHash(ctx, chunk.ChunkHash.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get chunk: %v", err)
|
t.Fatalf("failed to get chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved == nil {
|
if retrieved == nil {
|
||||||
t.Fatal("expected chunk, got nil")
|
t.Fatal("expected chunk, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved.ChunkHash != chunk.ChunkHash {
|
if retrieved.ChunkHash != chunk.ChunkHash {
|
||||||
t.Errorf("chunk hash mismatch: got %s, want %s", retrieved.ChunkHash, chunk.ChunkHash)
|
t.Errorf("chunk hash mismatch: got %s, want %s", retrieved.ChunkHash, chunk.ChunkHash)
|
||||||
}
|
}
|
||||||
if retrieved.SHA256 != chunk.SHA256 {
|
|
||||||
t.Errorf("sha256 mismatch: got %s, want %s", retrieved.SHA256, chunk.SHA256)
|
|
||||||
}
|
|
||||||
if retrieved.Size != chunk.Size {
|
if retrieved.Size != chunk.Size {
|
||||||
t.Errorf("size mismatch: got %d, want %d", retrieved.Size, chunk.Size)
|
t.Errorf("size mismatch: got %d, want %d", retrieved.Size, chunk.Size)
|
||||||
}
|
}
|
||||||
@@ -49,20 +53,23 @@ func TestChunkRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByHashes
|
// Test GetByHashes
|
||||||
chunk2 := &Chunk{
|
chunk2 := &database.Chunk{
|
||||||
ChunkHash: "chunkhash456",
|
ChunkHash: types.ChunkHash("chunkhash456"),
|
||||||
SHA256: "sha256hash456",
|
|
||||||
Size: 8192,
|
Size: 8192,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repo.Create(ctx, nil, chunk2)
|
err = repo.Create(ctx, nil, chunk2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create second chunk: %v", err)
|
t.Fatalf("failed to create second chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks, err := repo.GetByHashes(ctx, []string{chunk.ChunkHash, chunk2.ChunkHash})
|
chunks, err := repo.GetByHashes(ctx, []string{
|
||||||
|
chunk.ChunkHash.String(), chunk2.ChunkHash.String(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get chunks by hashes: %v", err)
|
t.Fatalf("failed to get chunks by hashes: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunks) != 2 {
|
if len(chunks) != 2 {
|
||||||
t.Errorf("expected 2 chunks, got %d", len(chunks))
|
t.Errorf("expected 2 chunks, got %d", len(chunks))
|
||||||
}
|
}
|
||||||
@@ -72,23 +79,27 @@ func TestChunkRepository(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to list unpacked chunks: %v", err)
|
t.Fatalf("failed to list unpacked chunks: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(unpacked) != 2 {
|
if len(unpacked) != 2 {
|
||||||
t.Errorf("expected 2 unpacked chunks, got %d", len(unpacked))
|
t.Errorf("expected 2 unpacked chunks, got %d", len(unpacked))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChunkRepositoryNotFound(t *testing.T) {
|
func TestChunkRepositoryNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewChunkRepository(db)
|
repo := database.NewChunkRepository(db)
|
||||||
|
|
||||||
// Test GetByHash with non-existent hash
|
// Test GetByHash with non-existent hash
|
||||||
chunk, err := repo.GetByHash(ctx, "nonexistent")
|
chunk, err := repo.GetByHash(ctx, "nonexistent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if chunk != nil {
|
if chunk != nil {
|
||||||
t.Error("expected nil for non-existent chunk")
|
t.Error("expected nil for non-existent chunk")
|
||||||
}
|
}
|
||||||
@@ -98,6 +109,7 @@ func TestChunkRepositoryNotFound(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if chunks != nil {
|
if chunks != nil {
|
||||||
t.Error("expected nil for empty hash list")
|
t.Error("expected nil for empty hash list")
|
||||||
}
|
}
|
||||||
|
|||||||
+521
-90
@@ -1,143 +1,574 @@
|
|||||||
|
// Package database provides the local SQLite index for Vaultik backup operations.
|
||||||
|
// The database tracks files, chunks, and their associations with blobs.
|
||||||
|
//
|
||||||
|
// Blobs in Vaultik are the final storage units uploaded to S3. Each blob is a
|
||||||
|
// large (up to 10GB) file containing many compressed and encrypted chunks from
|
||||||
|
// multiple source files. Blobs are content-addressed: the filename in S3 is
|
||||||
|
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the chunk data
|
||||||
|
// before compression and encryption (not from the stored bytes). See
|
||||||
|
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
||||||
|
//
|
||||||
|
// Schema is managed via numbered SQL migrations embedded in the schema/
|
||||||
|
// directory. Migration 000.sql bootstraps the schema_migrations tracking
|
||||||
|
// table; subsequent migrations (001, 002, …) are applied in order.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
// Register the pure-Go sqlite driver.
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errInvalidMigrationFilename is returned when an embedded migration file
|
||||||
|
// does not follow the "<version>[_<description>].sql" naming pattern.
|
||||||
|
var errInvalidMigrationFilename = errors.New("invalid migration filename")
|
||||||
|
|
||||||
|
//go:embed schema/*.sql
|
||||||
|
var schemaFS embed.FS
|
||||||
|
|
||||||
|
// bootstrapVersion is the migration that creates the schema_migrations
|
||||||
|
// table itself. It is applied before the normal migration loop.
|
||||||
|
const bootstrapVersion = 0
|
||||||
|
|
||||||
|
// busyTimeoutMs is how long a connection to the index waits for another
|
||||||
|
// connection's lock before failing with "database is locked".
|
||||||
|
const busyTimeoutMs = 10000
|
||||||
|
|
||||||
|
// DB represents the Vaultik local index database connection.
|
||||||
|
// It uses SQLite to track file metadata, content-defined chunks, and blob associations.
|
||||||
|
// The database enables incremental backups by detecting changed files and
|
||||||
|
// supports deduplication by tracking which chunks are already stored in blobs.
|
||||||
|
// Write operations are synchronized through a mutex to ensure thread safety.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
writeLock sync.Mutex
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ctx context.Context, path string) (*DB, error) {
|
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
||||||
conn, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=5000")
|
// filename. Filenames must follow the pattern "<version>.sql" or
|
||||||
|
// "<version>_<description>.sql", where version is a zero-padded numeric
|
||||||
|
// string (e.g. "001", "002"). Returns the version as an integer and an
|
||||||
|
// error if the filename does not match the expected pattern.
|
||||||
|
func ParseMigrationVersion(filename string) (int, error) {
|
||||||
|
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
if name == "" {
|
||||||
|
return 0, fmt.Errorf("%w %q: empty name", errInvalidMigrationFilename, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on underscore to separate version from description.
|
||||||
|
// If there's no underscore, the entire stem is the version.
|
||||||
|
versionStr := name
|
||||||
|
if before, _, ok := strings.Cut(name, "_"); ok {
|
||||||
|
versionStr = before
|
||||||
|
}
|
||||||
|
|
||||||
|
if versionStr == "" {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w %q: empty version prefix", errInvalidMigrationFilename, filename,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the version is purely numeric.
|
||||||
|
for _, ch := range versionStr {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w %q: version %q contains non-numeric character %q",
|
||||||
|
errInvalidMigrationFilename, filename, versionStr, string(ch),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := strconv.Atoi(versionStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening database: %w", err)
|
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := conn.PingContext(ctx); err != nil {
|
return version, nil
|
||||||
if closeErr := conn.Close(); closeErr != nil {
|
|
||||||
Fatal("failed to close database connection: %v", closeErr)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("pinging database: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db := &DB{conn: conn}
|
// indexDSN returns the driver DSN that opens the database at path. The
|
||||||
if err := db.createSchema(ctx); err != nil {
|
// driver runs each _pragma parameter on every connection it opens and drops
|
||||||
if closeErr := conn.Close(); closeErr != nil {
|
// any parameter it does not know without an error, so a setting written in
|
||||||
Fatal("failed to close database connection: %v", closeErr)
|
// another form silently does nothing. In WAL mode one connection can write
|
||||||
|
// while others read; the busy timeout makes a connection wait for a lock
|
||||||
|
// instead of failing at once.
|
||||||
|
func indexDSN(path string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s?_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)"+
|
||||||
|
"&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)",
|
||||||
|
path, busyTimeoutMs)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("creating schema: %w", err)
|
|
||||||
|
// New creates a new database connection at the specified path.
|
||||||
|
// It creates the schema if needed. Every connection runs in WAL mode with
|
||||||
|
// a busy timeout and foreign keys on (see indexDSN), so a read-only command
|
||||||
|
// can read the index while a backup writes to it. Committed rows can sit in
|
||||||
|
// the -wal file beside the database until a checkpoint, so a copy of the
|
||||||
|
// database file alone may miss them.
|
||||||
|
// SQLite handles crash recovery automatically when opening a database with
|
||||||
|
// journal/WAL files present.
|
||||||
|
// The path parameter can be a file path for persistent storage or ":memory:"
|
||||||
|
// for an in-memory database (useful for testing).
|
||||||
|
func New(ctx context.Context, path string) (*DB, error) {
|
||||||
|
log.Debug("Opening database connection", "path", path)
|
||||||
|
|
||||||
|
// Note: We do NOT delete journal/WAL files before opening.
|
||||||
|
// SQLite handles crash recovery automatically when the database is opened.
|
||||||
|
// Deleting these files would corrupt the database after an unclean shutdown.
|
||||||
|
|
||||||
|
// First attempt with standard WAL mode
|
||||||
|
log.Debug("Attempting to open database with WAL mode", "path", path)
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", indexDSN(path))
|
||||||
|
if err == nil {
|
||||||
|
configureConnPool(conn)
|
||||||
|
|
||||||
|
err = conn.PingContext(ctx)
|
||||||
|
if err == nil {
|
||||||
|
// Success on first try
|
||||||
|
log.Debug("Database opened successfully with WAL mode", "path", path)
|
||||||
|
|
||||||
|
return finishOpen(ctx, conn, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(
|
||||||
|
"Failed to ping database, closing connection",
|
||||||
|
"path", path, "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the first attempt failed, try once more
|
||||||
|
return retryOpen(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureConnPool serializes all database access through one connection.
|
||||||
|
// SQLite can handle multiple readers but only one writer at a time; setting
|
||||||
|
// MaxOpenConns to 1 ensures all writes go through a single connection,
|
||||||
|
// preventing SQLITE_BUSY errors.
|
||||||
|
func configureConnPool(conn *sql.DB) {
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
conn.SetMaxIdleConns(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishOpen wraps the connection and applies any pending migrations. On
|
||||||
|
// migration failure the connection is closed.
|
||||||
|
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
||||||
|
db := &DB{conn: conn, path: path}
|
||||||
|
|
||||||
|
err := applyMigrations(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("applying migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Close() error {
|
// retryOpen makes a second attempt to open the database, with the same
|
||||||
if err := db.conn.Close(); err != nil {
|
// settings, after the first attempt failed, for example because another
|
||||||
Fatal("failed to close database: %v", err)
|
// process held a lock for longer than the busy timeout.
|
||||||
|
func retryOpen(ctx context.Context, path string) (*DB, error) {
|
||||||
|
log.Info("Database appears locked, retrying open", "path", path)
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", indexDSN(path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening database on retry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureConnPool(conn)
|
||||||
|
|
||||||
|
err = conn.PingContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug(
|
||||||
|
"Failed to ping database on retry, closing",
|
||||||
|
"path", path, "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"database still locked on retry: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := finishOpen(ctx, conn, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Database connection established successfully", "path", path)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errUntrustedSnapshotSchema is returned when a downloaded snapshot
|
||||||
|
// database carries schema objects the real schema never defines, or is
|
||||||
|
// missing a table the restore and deep-verify queries read.
|
||||||
|
var errUntrustedSnapshotSchema = errors.New(
|
||||||
|
"downloaded snapshot database has an untrusted schema")
|
||||||
|
|
||||||
|
// snapshotReadOnlyDSN builds the driver DSN that opens a materialized
|
||||||
|
// snapshot database file read-only. mode=ro opens the file read-only at
|
||||||
|
// the OS level, query_only rejects any write the engine is asked to make,
|
||||||
|
// and trusted_schema=OFF refuses to run application code named in the
|
||||||
|
// schema. The file: URI form is required for the driver to honour the
|
||||||
|
// mode parameter.
|
||||||
|
func snapshotReadOnlyDSN(path string) string {
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "file",
|
||||||
|
Path: path,
|
||||||
|
RawQuery: "mode=ro&_pragma=query_only(true)&_pragma=trusted_schema(false)",
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReadOnly opens an already-materialized SQLite file for read-only
|
||||||
|
// querying of a snapshot database downloaded from the store, used by
|
||||||
|
// restore and deep verify. Unlike New it never applies schema migrations
|
||||||
|
// and never writes: the connection is opened read-only with query_only
|
||||||
|
// and trusted_schema=OFF. It refuses any file whose schema carries a
|
||||||
|
// trigger, view or virtual table, or lacks an expected table, so a forged
|
||||||
|
// file cannot redefine what the restore queries return. The caller owns
|
||||||
|
// the file and must remove it.
|
||||||
|
func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening read-only database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configureConnPool(conn)
|
||||||
|
|
||||||
|
err = conn.PingContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("opening read-only database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = verifySnapshotSchema(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{conn: conn, path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySnapshotSchema rejects a downloaded database whose schema is not
|
||||||
|
// the plain table set the real schema defines. Any trigger, view or
|
||||||
|
// virtual table, or a missing expected table, fails the open.
|
||||||
|
func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error {
|
||||||
|
// expectedSnapshotTables are the tables the restore and deep-verify
|
||||||
|
// queries read. A downloaded database missing any of them is not a
|
||||||
|
// genuine snapshot database and is refused.
|
||||||
|
expectedSnapshotTables := []string{
|
||||||
|
"blob_chunks",
|
||||||
|
"blobs",
|
||||||
|
"chunks",
|
||||||
|
"file_chunks",
|
||||||
|
"files",
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := conn.QueryContext(
|
||||||
|
ctx, "SELECT type, name, sql FROM sqlite_master")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading snapshot schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
present := make(map[string]struct{})
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var objType, name string
|
||||||
|
|
||||||
|
var objSQL sql.NullString
|
||||||
|
|
||||||
|
err = rows.Scan(&objType, &name, &objSQL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading snapshot schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch objType {
|
||||||
|
case "trigger", "view":
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: unexpected %s %q", errUntrustedSnapshotSchema, objType, name)
|
||||||
|
case "table":
|
||||||
|
if isVirtualTableSQL(objSQL.String) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: unexpected virtual table %q",
|
||||||
|
errUntrustedSnapshotSchema, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
present[name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading snapshot schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range expectedSnapshotTables {
|
||||||
|
if _, ok := present[table]; !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: missing table %q", errUntrustedSnapshotSchema, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isVirtualTableSQL reports whether a sqlite_master row's SQL defines a
|
||||||
|
// virtual table. Virtual tables are recorded with type 'table' but a
|
||||||
|
// "CREATE VIRTUAL TABLE" definition and can run module code, so they are
|
||||||
|
// refused alongside triggers and views.
|
||||||
|
func isVirtualTableSQL(createSQL string) bool {
|
||||||
|
return strings.HasPrefix(
|
||||||
|
strings.ToUpper(strings.TrimSpace(createSQL)), "CREATE VIRTUAL TABLE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestDB creates an in-memory SQLite database for testing purposes.
|
||||||
|
// The database is automatically initialized with the schema and is ready
|
||||||
|
// for use. Each call creates a new independent database instance.
|
||||||
|
func NewTestDB() (*DB, error) {
|
||||||
|
return New(context.Background(), ":memory:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
// It ensures all pending operations are completed before closing.
|
||||||
|
// Returns an error if the database connection cannot be closed properly.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
log.Debug("Closing database connection", "path", db.path)
|
||||||
|
|
||||||
|
err := db.conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to close database", "path", db.path, "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to close database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Database connection closed successfully", "path", db.path)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conn returns the underlying *sql.DB connection.
|
||||||
|
// This should be used sparingly and primarily for read operations.
|
||||||
|
// For write operations, prefer using the ExecWithLog method.
|
||||||
func (db *DB) Conn() *sql.DB {
|
func (db *DB) Conn() *sql.DB {
|
||||||
return db.conn
|
return db.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
// Path returns the path to the database file.
|
||||||
|
func (db *DB) Path() string {
|
||||||
|
return db.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginTx starts a new database transaction with the given options.
|
||||||
|
// The caller is responsible for committing or rolling back the transaction.
|
||||||
|
// For write transactions, consider using the Repositories.WithTx method instead,
|
||||||
|
// which handles locking and rollback automatically.
|
||||||
|
func (db *DB) BeginTx(
|
||||||
|
ctx context.Context,
|
||||||
|
opts *sql.TxOptions,
|
||||||
|
) (*sql.Tx, error) {
|
||||||
return db.conn.BeginTx(ctx, opts)
|
return db.conn.BeginTx(ctx, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockForWrite acquires the write lock
|
// Note: LockForWrite and UnlockWrite methods have been removed.
|
||||||
func (db *DB) LockForWrite() {
|
// SQLite handles its own locking internally, so explicit locking is not needed.
|
||||||
db.writeLock.Lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlockWrite releases the write lock
|
// ExecWithLog executes a write query with SQL logging.
|
||||||
func (db *DB) UnlockWrite() {
|
// SQLite handles its own locking internally, so we just pass through to ExecContext.
|
||||||
db.writeLock.Unlock()
|
// The query and args parameters follow the same format as sql.DB.ExecContext.
|
||||||
}
|
func (db *DB) ExecWithLog(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
args ...any,
|
||||||
|
) (sql.Result, error) {
|
||||||
|
LogSQL("Execute", query, args...)
|
||||||
|
|
||||||
// ExecWithLock executes a write query with the write lock held
|
|
||||||
func (db *DB) ExecWithLock(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
|
||||||
db.writeLock.Lock()
|
|
||||||
defer db.writeLock.Unlock()
|
|
||||||
return db.conn.ExecContext(ctx, query, args...)
|
return db.conn.ExecContext(ctx, query, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryRowWithLock executes a write query that returns a row with the write lock held
|
// QueryRowWithLog executes a query that returns at most one row with SQL
|
||||||
func (db *DB) QueryRowWithLock(ctx context.Context, query string, args ...interface{}) *sql.Row {
|
// logging. This is useful for queries that modify data and return values
|
||||||
db.writeLock.Lock()
|
// (e.g., INSERT ... RETURNING). SQLite handles its own locking internally.
|
||||||
defer db.writeLock.Unlock()
|
// The query and args parameters follow the same format as
|
||||||
|
// sql.DB.QueryRowContext.
|
||||||
|
func (db *DB) QueryRowWithLog(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
args ...any,
|
||||||
|
) *sql.Row {
|
||||||
|
LogSQL("QueryRow", query, args...)
|
||||||
|
|
||||||
return db.conn.QueryRowContext(ctx, query, args...)
|
return db.conn.QueryRowContext(ctx, query, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) createSchema(ctx context.Context) error {
|
// collectMigrations reads the embedded schema directory and returns
|
||||||
schema := `
|
// migration filenames sorted lexicographically.
|
||||||
CREATE TABLE IF NOT EXISTS files (
|
func collectMigrations() ([]string, error) {
|
||||||
path TEXT PRIMARY KEY,
|
entries, err := schemaFS.ReadDir("schema")
|
||||||
mtime INTEGER NOT NULL,
|
if err != nil {
|
||||||
ctime INTEGER NOT NULL,
|
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
||||||
size INTEGER NOT NULL,
|
}
|
||||||
mode INTEGER NOT NULL,
|
|
||||||
uid INTEGER NOT NULL,
|
|
||||||
gid INTEGER NOT NULL,
|
|
||||||
link_target TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_chunks (
|
var migrations []string
|
||||||
path TEXT NOT NULL,
|
|
||||||
idx INTEGER NOT NULL,
|
|
||||||
chunk_hash TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (path, idx)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chunks (
|
for _, entry := range entries {
|
||||||
chunk_hash TEXT PRIMARY KEY,
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
sha256 TEXT NOT NULL,
|
migrations = append(migrations, entry.Name())
|
||||||
size INTEGER NOT NULL
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blobs (
|
sort.Strings(migrations)
|
||||||
blob_hash TEXT PRIMARY KEY,
|
|
||||||
created_ts INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blob_chunks (
|
return migrations, nil
|
||||||
blob_hash TEXT NOT NULL,
|
}
|
||||||
chunk_hash TEXT NOT NULL,
|
|
||||||
offset INTEGER NOT NULL,
|
|
||||||
length INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (blob_hash, chunk_hash)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chunk_files (
|
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||||
chunk_hash TEXT NOT NULL,
|
// by applying 000.sql if the table is missing.
|
||||||
file_path TEXT NOT NULL,
|
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB) error {
|
||||||
file_offset INTEGER NOT NULL,
|
var tableExists int
|
||||||
length INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (chunk_hash, file_path)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS snapshots (
|
err := db.QueryRowContext(ctx,
|
||||||
id TEXT PRIMARY KEY,
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
hostname TEXT NOT NULL,
|
).Scan(&tableExists)
|
||||||
vaultik_version TEXT NOT NULL,
|
if err != nil {
|
||||||
created_ts INTEGER NOT NULL,
|
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||||
file_count INTEGER NOT NULL,
|
}
|
||||||
chunk_count INTEGER NOT NULL,
|
|
||||||
blob_count INTEGER NOT NULL,
|
|
||||||
total_size INTEGER NOT NULL,
|
|
||||||
blob_size INTEGER NOT NULL,
|
|
||||||
compression_ratio REAL NOT NULL
|
|
||||||
);
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := db.conn.ExecContext(ctx, schema)
|
if tableExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := schemaFS.ReadFile("schema/000.sql")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMigrations applies all pending migrations to db. It first bootstraps
|
||||||
|
// the schema_migrations table via 000.sql, then iterates through remaining
|
||||||
|
// migration files in order.
|
||||||
|
func applyMigrations(ctx context.Context, db *sql.DB) error {
|
||||||
|
err := bootstrapMigrationsTable(ctx, db)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrations, err := collectMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already applied.
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||||
|
version,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check migration status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
log.Debug("migration already applied", "version", version)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and apply migration.
|
||||||
|
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("applying migration", "version", version)
|
||||||
|
|
||||||
|
_, execErr := db.ExecContext(ctx, string(content))
|
||||||
|
if execErr != nil {
|
||||||
|
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record migration as applied.
|
||||||
|
_, recErr := db.ExecContext(ctx,
|
||||||
|
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("migration applied successfully", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeatPlaceholder generates a string of ", ?" repeated n times for IN
|
||||||
|
// clause construction. For example, repeatPlaceholder(2) returns ", ?, ?".
|
||||||
|
func repeatPlaceholder(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Repeat(", ?", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSQL logs SQL queries and their arguments when debug mode is enabled.
|
||||||
|
// Debug mode is activated by setting the GODEBUG environment variable to
|
||||||
|
// include "vaultik". This is useful for troubleshooting database operations
|
||||||
|
// and understanding query patterns.
|
||||||
|
//
|
||||||
|
// The operation parameter describes the type of SQL operation (e.g.,
|
||||||
|
// "Execute", "Query"). The query parameter is the SQL statement being
|
||||||
|
// executed. The args parameter contains the query arguments that will be
|
||||||
|
// interpolated.
|
||||||
|
func LogSQL(operation, query string, args ...any) {
|
||||||
|
if strings.Contains(os.Getenv("GODEBUG"), "vaultik") {
|
||||||
|
log.Debug(
|
||||||
|
"SQL "+operation,
|
||||||
|
"query",
|
||||||
|
strings.TrimSpace(query),
|
||||||
|
"args",
|
||||||
|
fmt.Sprintf("%v", args),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
|
//nolint:testpackage // exercises unexported migration internals
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDatabase(t *testing.T) {
|
func TestDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
|
||||||
@@ -15,8 +19,10 @@ func TestDatabase(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create database: %v", err)
|
t.Fatalf("failed to create database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := db.Close(); err != nil {
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
t.Errorf("failed to close database: %v", err)
|
t.Errorf("failed to close database: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -26,16 +32,20 @@ func TestDatabase(t *testing.T) {
|
|||||||
t.Fatal("database connection is nil")
|
t.Fatal("database connection is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test schema creation (already done in New)
|
// Test schema creation (already done in New via migrations)
|
||||||
// Verify tables exist
|
// Verify tables exist
|
||||||
tables := []string{
|
tables := []string{
|
||||||
|
"schema_migrations",
|
||||||
"files", "file_chunks", "chunks", "blobs",
|
"files", "file_chunks", "chunks", "blobs",
|
||||||
"blob_chunks", "chunk_files", "snapshots",
|
"blob_chunks", "chunk_files", "snapshots",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var name string
|
var name string
|
||||||
err := db.conn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
|
|
||||||
|
err := db.conn.QueryRowContext(ctx,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
|
||||||
|
).Scan(&name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("table %s does not exist: %v", table, err)
|
t.Errorf("table %s does not exist: %v", table, err)
|
||||||
}
|
}
|
||||||
@@ -43,6 +53,8 @@ func TestDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDatabaseInvalidPath(t *testing.T) {
|
func TestDatabaseInvalidPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Test with invalid path
|
// Test with invalid path
|
||||||
@@ -53,6 +65,8 @@ func TestDatabaseInvalidPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDatabaseConcurrentAccess(t *testing.T) {
|
func TestDatabaseConcurrentAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
|
||||||
@@ -60,37 +74,314 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create database: %v", err)
|
t.Fatalf("failed to create database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := db.Close(); err != nil {
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
t.Errorf("failed to close database: %v", err)
|
t.Errorf("failed to close database: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Test concurrent writes
|
// Test concurrent writes
|
||||||
done := make(chan bool, 10)
|
type result struct {
|
||||||
for i := 0; i < 10; i++ {
|
index int
|
||||||
go func(i int) {
|
err error
|
||||||
_, err := db.ExecWithLock(ctx, "INSERT INTO chunks (chunk_hash, sha256, size) VALUES (?, ?, ?)",
|
|
||||||
fmt.Sprintf("hash%d", i), fmt.Sprintf("sha%d", i), i*1024)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("concurrent insert failed: %v", err)
|
|
||||||
}
|
}
|
||||||
done <- true
|
|
||||||
|
results := make(chan result, 10)
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
go func(i int) {
|
||||||
|
_, err := db.ExecWithLog(ctx,
|
||||||
|
"INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)",
|
||||||
|
fmt.Sprintf("hash%d", i), i*1024)
|
||||||
|
results <- result{index: i, err: err}
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all goroutines
|
// Wait for all goroutines and check results
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
<-done
|
r := <-results
|
||||||
|
if r.err != nil {
|
||||||
|
t.Fatalf("concurrent insert %d failed: %v", r.index, r.err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all inserts succeeded
|
// Verify all inserts succeeded
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
err = db.conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
|
err = db.conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to count chunks: %v", err)
|
t.Fatalf("failed to count chunks: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 10 {
|
if count != 10 {
|
||||||
t.Errorf("expected 10 chunks, got %d", count)
|
t.Errorf("expected 10 chunks, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewSetsJournalModeAndBusyTimeout checks that the connection settings
|
||||||
|
// New passes reach SQLite. The driver drops a setting it does not recognise
|
||||||
|
// without an error, so only reading the value back shows it took effect.
|
||||||
|
func TestNewSetsJournalModeAndBusyTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := New(ctx, filepath.Join(t.TempDir(), "index.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
var journalMode string
|
||||||
|
|
||||||
|
err = db.conn.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading journal_mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalMode != "wal" {
|
||||||
|
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
var busyTimeout int
|
||||||
|
|
||||||
|
err = db.conn.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading busy_timeout: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if busyTimeout != busyTimeoutMs {
|
||||||
|
t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriteSucceedsWhileAnotherHandleReads opens the same index twice,
|
||||||
|
// as a read-only command does while a backup runs, and checks that a write
|
||||||
|
// on one handle commits while the other is in the middle of a read.
|
||||||
|
func TestNewWriteSucceedsWhileAnotherHandleReads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "index.db")
|
||||||
|
|
||||||
|
reader, err := New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open reading handle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
|
writer, err := New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open writing handle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = writer.Close() }()
|
||||||
|
|
||||||
|
// The read lock taken by the SELECT is held until the transaction ends.
|
||||||
|
readTx, err := reader.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("beginning read transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = readTx.Rollback() }()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err = readTx.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading chunks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = writer.ExecWithLog(ctx,
|
||||||
|
"INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)", "hash", 1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write while another handle reads: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
wantVer int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "valid 000.sql", filename: "000.sql", wantVer: 0, wantError: false},
|
||||||
|
{name: "valid 001.sql", filename: "001.sql", wantVer: 1, wantError: false},
|
||||||
|
{name: "valid 099.sql", filename: "099.sql", wantVer: 99, wantError: false},
|
||||||
|
{
|
||||||
|
name: "valid with description", filename: "001_initial_schema.sql",
|
||||||
|
wantVer: 1, wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid large version", filename: "123_big_migration.sql",
|
||||||
|
wantVer: 123, wantError: false,
|
||||||
|
},
|
||||||
|
{name: "invalid alpha version", filename: "abc.sql", wantVer: 0, wantError: true},
|
||||||
|
{name: "invalid mixed chars", filename: "12a.sql", wantVer: 0, wantError: true},
|
||||||
|
{name: "invalid no extension", filename: "schema.sql", wantVer: 0, wantError: true},
|
||||||
|
{name: "empty string", filename: "", wantVer: 0, wantError: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseMigrationVersion(tc.filename)
|
||||||
|
if tc.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) = %d, nil; want error",
|
||||||
|
tc.filename, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v",
|
||||||
|
tc.filename, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tc.wantVer {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) = %d; want %d",
|
||||||
|
tc.filename, got, tc.wantVer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_Idempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", ":memory:?_foreign_keys=ON")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to close database: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
conn.SetMaxIdleConns(1)
|
||||||
|
|
||||||
|
// First run: apply all migrations.
|
||||||
|
err = applyMigrations(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first applyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count rows in schema_migrations after first run.
|
||||||
|
var countBefore int
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations",
|
||||||
|
).Scan(&countBefore)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count schema_migrations after first run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second run: must be a no-op.
|
||||||
|
err = applyMigrations(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second applyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count rows in schema_migrations after second run — must be unchanged.
|
||||||
|
var countAfter int
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations",
|
||||||
|
).Scan(&countAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count schema_migrations after second run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countBefore != countAfter {
|
||||||
|
t.Errorf("schema_migrations row count changed: before=%d, after=%d",
|
||||||
|
countBefore, countAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", ":memory:?_foreign_keys=ON")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to close database: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
conn.SetMaxIdleConns(1)
|
||||||
|
|
||||||
|
// Verify schema_migrations does NOT exist yet.
|
||||||
|
var tableBefore int
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableBefore)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table before bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableBefore != 0 {
|
||||||
|
t.Fatal("schema_migrations table should not exist before bootstrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run bootstrap.
|
||||||
|
err = bootstrapMigrationsTable(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify schema_migrations now exists.
|
||||||
|
var tableAfter int
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableAfter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table after bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableAfter != 1 {
|
||||||
|
t.Fatalf("schema_migrations table should exist after bootstrap, got count=%d",
|
||||||
|
tableAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify version 0 row exists.
|
||||||
|
var version int
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT version FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("version 0 row not found in schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version != 0 {
|
||||||
|
t.Errorf("expected version 0, got %d", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fatal prints an error message to stderr and exits with status 1
|
|
||||||
func Fatal(format string, args ...interface{}) {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", args...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseRows closes rows and exits on error
|
|
||||||
func CloseRows(rows *sql.Rows) {
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
Fatal("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,28 +4,38 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FileChunkRepository provides access to the file_chunks table, which maps
|
||||||
|
// files to their ordered constituent chunks.
|
||||||
type FileChunkRepository struct {
|
type FileChunkRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewFileChunkRepository creates a FileChunkRepository backed by db.
|
||||||
func NewFileChunkRepository(db *DB) *FileChunkRepository {
|
func NewFileChunkRepository(db *DB) *FileChunkRepository {
|
||||||
return &FileChunkRepository{db: db}
|
return &FileChunkRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FileChunkRepository) Create(ctx context.Context, tx *sql.Tx, fc *FileChunk) error {
|
// Create inserts a file_chunks row (idempotently), using tx when non-nil.
|
||||||
|
func (r *FileChunkRepository) Create(
|
||||||
|
ctx context.Context, tx *sql.Tx, fc *FileChunk,
|
||||||
|
) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO file_chunks (path, idx, chunk_hash)
|
INSERT INTO file_chunks (file_id, idx, chunk_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(path, idx) DO NOTHING
|
ON CONFLICT(file_id, idx) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, fc.Path, fc.Idx, fc.ChunkHash)
|
_, err = tx.ExecContext(ctx, query, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, fc.Path, fc.Idx, fc.ChunkHash)
|
_, err = r.db.ExecWithLog(ctx, query,
|
||||||
|
fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,41 +45,90 @@ func (r *FileChunkRepository) Create(ctx context.Context, tx *sql.Tx, fc *FileCh
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FileChunkRepository) GetByPath(ctx context.Context, path string) ([]*FileChunk, error) {
|
// GetByPath returns the ordered chunks of the file at the given path.
|
||||||
|
func (r *FileChunkRepository) GetByPath(
|
||||||
|
ctx context.Context, path string,
|
||||||
|
) ([]*FileChunk, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT path, idx, chunk_hash
|
SELECT fc.file_id, fc.idx, fc.chunk_hash
|
||||||
FROM file_chunks
|
FROM file_chunks fc
|
||||||
WHERE path = ?
|
JOIN files f ON fc.file_id = f.id
|
||||||
ORDER BY idx
|
WHERE f.path = ?
|
||||||
|
ORDER BY fc.idx
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, path)
|
rows, err := r.db.conn.QueryContext(ctx, query, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
|
||||||
var fileChunks []*FileChunk
|
defer func() { _ = rows.Close() }()
|
||||||
for rows.Next() {
|
|
||||||
var fc FileChunk
|
return r.scanFileChunks(rows)
|
||||||
err := rows.Scan(&fc.Path, &fc.Idx, &fc.ChunkHash)
|
}
|
||||||
|
|
||||||
|
// GetByFileID retrieves file chunks by file ID
|
||||||
|
func (r *FileChunkRepository) GetByFileID(
|
||||||
|
ctx context.Context, fileID types.FileID,
|
||||||
|
) ([]*FileChunk, error) {
|
||||||
|
query := `
|
||||||
|
SELECT file_id, idx, chunk_hash
|
||||||
|
FROM file_chunks
|
||||||
|
WHERE file_id = ?
|
||||||
|
ORDER BY idx
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, fileID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning file chunk: %w", err)
|
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||||
}
|
|
||||||
fileChunks = append(fileChunks, &fc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileChunks, rows.Err()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
return r.scanFileChunks(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FileChunkRepository) DeleteByPath(ctx context.Context, tx *sql.Tx, path string) error {
|
// GetByPathTx retrieves file chunks within a transaction
|
||||||
query := `DELETE FROM file_chunks WHERE path = ?`
|
func (r *FileChunkRepository) GetByPathTx(
|
||||||
|
ctx context.Context, tx *sql.Tx, path string,
|
||||||
|
) ([]*FileChunk, error) {
|
||||||
|
query := `
|
||||||
|
SELECT fc.file_id, fc.idx, fc.chunk_hash
|
||||||
|
FROM file_chunks fc
|
||||||
|
JOIN files f ON fc.file_id = f.id
|
||||||
|
WHERE f.path = ?
|
||||||
|
ORDER BY fc.idx
|
||||||
|
`
|
||||||
|
|
||||||
|
LogSQL("GetByPathTx", query, path)
|
||||||
|
|
||||||
|
rows, err := tx.QueryContext(ctx, query, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
fileChunks, err := r.scanFileChunks(rows)
|
||||||
|
LogSQL("GetByPathTx", "Complete", path, "count", len(fileChunks))
|
||||||
|
|
||||||
|
return fileChunks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByPath deletes all file_chunks rows for the file at the given path.
|
||||||
|
func (r *FileChunkRepository) DeleteByPath(
|
||||||
|
ctx context.Context, tx *sql.Tx, path string,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM file_chunks
|
||||||
|
WHERE file_id = (SELECT id FROM files WHERE path = ?)
|
||||||
|
`
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, path)
|
_, err = tx.ExecContext(ctx, query, path)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, path)
|
_, err = r.db.ExecWithLog(ctx, query, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,3 +137,171 @@ func (r *FileChunkRepository) DeleteByPath(ctx context.Context, tx *sql.Tx, path
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteByFileID deletes all chunks for a file by its UUID
|
||||||
|
func (r *FileChunkRepository) DeleteByFileID(
|
||||||
|
ctx context.Context, tx *sql.Tx, fileID types.FileID,
|
||||||
|
) error {
|
||||||
|
query := `DELETE FROM file_chunks WHERE file_id = ?`
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, fileID.String())
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, fileID.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting file chunks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByFileIDs deletes all chunks for multiple files in a single statement.
|
||||||
|
//
|
||||||
|
//nolint:dupl // symmetric implementation for a parallel association table
|
||||||
|
func (r *FileChunkRepository) DeleteByFileIDs(
|
||||||
|
ctx context.Context, tx *sql.Tx, fileIDs []types.FileID,
|
||||||
|
) error {
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch at 500 to stay within SQLite's variable limit
|
||||||
|
const batchSize = 500
|
||||||
|
|
||||||
|
for i := 0; i < len(fileIDs); i += batchSize {
|
||||||
|
end := min(i+batchSize, len(fileIDs))
|
||||||
|
|
||||||
|
batch := fileIDs[i:end]
|
||||||
|
|
||||||
|
//nolint:gosec // G202: concatenates constant SQL and "?" placeholders only
|
||||||
|
query := "DELETE FROM file_chunks WHERE file_id IN (?" +
|
||||||
|
repeatPlaceholder(len(batch)-1) + ")"
|
||||||
|
|
||||||
|
args := make([]any, len(batch))
|
||||||
|
for j, id := range batch {
|
||||||
|
args[j] = id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, args...)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch deleting file_chunks: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBatch inserts multiple file_chunks in a single statement for efficiency.
|
||||||
|
// Batches are automatically split to stay within SQLite's variable limit.
|
||||||
|
func (r *FileChunkRepository) CreateBatch(
|
||||||
|
ctx context.Context, tx *sql.Tx, fcs []FileChunk,
|
||||||
|
) error {
|
||||||
|
if len(fcs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each file_chunks row binds this many SQL variables.
|
||||||
|
const fileChunkCols = 3
|
||||||
|
|
||||||
|
// SQLite has a limit on variables (typically 999 or 32766), so batch
|
||||||
|
// at 300 rows to be safe.
|
||||||
|
const batchSize = 300
|
||||||
|
|
||||||
|
for i := 0; i < len(fcs); i += batchSize {
|
||||||
|
end := min(i+batchSize, len(fcs))
|
||||||
|
|
||||||
|
batch := fcs[i:end]
|
||||||
|
|
||||||
|
// Build the query with multiple value sets
|
||||||
|
query := "INSERT INTO file_chunks (file_id, idx, chunk_hash) VALUES "
|
||||||
|
|
||||||
|
args := make([]any, 0, len(batch)*fileChunkCols)
|
||||||
|
|
||||||
|
var querySb211 strings.Builder
|
||||||
|
|
||||||
|
for j, fc := range batch {
|
||||||
|
if j > 0 {
|
||||||
|
querySb211.WriteString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
querySb211.WriteString("(?, ?, ?)")
|
||||||
|
|
||||||
|
args = append(args, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += querySb211.String()
|
||||||
|
|
||||||
|
query += " ON CONFLICT(file_id, idx) DO NOTHING"
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, args...)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch inserting file_chunks: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByFile is an alias for GetByPath for compatibility
|
||||||
|
func (r *FileChunkRepository) GetByFile(
|
||||||
|
ctx context.Context, path string,
|
||||||
|
) ([]*FileChunk, error) {
|
||||||
|
LogSQL("GetByFile", "Starting", path)
|
||||||
|
result, err := r.GetByPath(ctx, path)
|
||||||
|
LogSQL("GetByFile", "Complete", path, "count", len(result))
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByFileTx retrieves file chunks within a transaction
|
||||||
|
func (r *FileChunkRepository) GetByFileTx(
|
||||||
|
ctx context.Context, tx *sql.Tx, path string,
|
||||||
|
) ([]*FileChunk, error) {
|
||||||
|
LogSQL("GetByFileTx", "Starting", path)
|
||||||
|
result, err := r.GetByPathTx(ctx, tx, path)
|
||||||
|
LogSQL("GetByFileTx", "Complete", path, "count", len(result))
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanFileChunks is a helper that scans file chunk rows
|
||||||
|
func (r *FileChunkRepository) scanFileChunks(rows *sql.Rows) ([]*FileChunk, error) {
|
||||||
|
var fileChunks []*FileChunk
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
fc FileChunk
|
||||||
|
fileIDStr, chunkHashStr string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(&fileIDStr, &fc.Idx, &chunkHashStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file chunk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fc.FileID, err = types.ParseFileID(fileIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing file ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fc.ChunkHash = types.ChunkHash(chunkHashStr)
|
||||||
|
fileChunks = append(fileChunks, &fc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileChunks, rows.Err()
|
||||||
|
}
|
||||||
@@ -1,23 +1,46 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileChunkRepository(t *testing.T) {
|
func TestFileChunkRepository(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewFileChunkRepository(db)
|
repo := database.NewFileChunkRepository(db)
|
||||||
|
fileRepo := database.NewFileRepository(db)
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
// Create test file first
|
||||||
|
testTime := time.Now().Truncate(time.Second)
|
||||||
|
file := &database.File{
|
||||||
|
Path: testFileTxt,
|
||||||
|
MTime: testTime,
|
||||||
|
Size: 3072,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
LinkTarget: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
mustCreateFile(t, fileRepo, file)
|
||||||
|
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash)
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
fc1 := &FileChunk{
|
fc1 := &database.FileChunk{
|
||||||
Path: "/test/file.txt",
|
FileID: file.ID,
|
||||||
Idx: 0,
|
Idx: 0,
|
||||||
ChunkHash: "chunk1",
|
ChunkHash: types.ChunkHash(chunk1Hash),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repo.Create(ctx, nil, fc1)
|
err := repo.Create(ctx, nil, fc1)
|
||||||
@@ -26,37 +49,40 @@ func TestFileChunkRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add more chunks for the same file
|
// Add more chunks for the same file
|
||||||
fc2 := &FileChunk{
|
fc2 := &database.FileChunk{
|
||||||
Path: "/test/file.txt",
|
FileID: file.ID,
|
||||||
Idx: 1,
|
Idx: 1,
|
||||||
ChunkHash: "chunk2",
|
ChunkHash: types.ChunkHash(chunk2Hash),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repo.Create(ctx, nil, fc2)
|
err = repo.Create(ctx, nil, fc2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create second file chunk: %v", err)
|
t.Fatalf("failed to create second file chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fc3 := &FileChunk{
|
fc3 := &database.FileChunk{
|
||||||
Path: "/test/file.txt",
|
FileID: file.ID,
|
||||||
Idx: 2,
|
Idx: 2,
|
||||||
ChunkHash: "chunk3",
|
ChunkHash: types.ChunkHash(chunk3Hash),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repo.Create(ctx, nil, fc3)
|
err = repo.Create(ctx, nil, fc3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create third file chunk: %v", err)
|
t.Fatalf("failed to create third file chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByPath
|
// Test GetByFile
|
||||||
chunks, err := repo.GetByPath(ctx, "/test/file.txt")
|
fileChunks, err := repo.GetByFile(ctx, testFileTxt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get file chunks: %v", err)
|
t.Fatalf("failed to get file chunks: %v", err)
|
||||||
}
|
}
|
||||||
if len(chunks) != 3 {
|
|
||||||
t.Errorf("expected 3 chunks, got %d", len(chunks))
|
if len(fileChunks) != 3 {
|
||||||
|
t.Errorf("expected 3 chunks, got %d", len(fileChunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify order
|
// Verify order
|
||||||
for i, chunk := range chunks {
|
for i, chunk := range fileChunks {
|
||||||
if chunk.Idx != i {
|
if chunk.Idx != i {
|
||||||
t.Errorf("wrong chunk order: expected idx %d, got %d", i, chunk.Idx)
|
t.Errorf("wrong chunk order: expected idx %d, got %d", i, chunk.Idx)
|
||||||
}
|
}
|
||||||
@@ -67,38 +93,116 @@ func TestFileChunkRepository(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create duplicate file chunk: %v", err)
|
t.Fatalf("failed to create duplicate file chunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test DeleteByPath
|
|
||||||
err = repo.DeleteByPath(ctx, nil, "/test/file.txt")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to delete file chunks: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks, err = repo.GetByPath(ctx, "/test/file.txt")
|
func TestFileChunkRepositoryDeleteByFileID(t *testing.T) {
|
||||||
if err != nil {
|
t.Parallel()
|
||||||
t.Fatalf("failed to get deleted file chunks: %v", err)
|
|
||||||
}
|
|
||||||
if len(chunks) != 0 {
|
|
||||||
t.Errorf("expected 0 chunks after delete, got %d", len(chunks))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewFileChunkRepository(db)
|
repo := database.NewFileChunkRepository(db)
|
||||||
|
fileRepo := database.NewFileRepository(db)
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
file := &database.File{
|
||||||
|
Path: testFileTxt,
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
mustCreateFile(t, fileRepo, file)
|
||||||
|
mustCreateChunks(t, repos, chunk1Hash)
|
||||||
|
|
||||||
|
fc := &database.FileChunk{
|
||||||
|
FileID: file.ID,
|
||||||
|
Idx: 0,
|
||||||
|
ChunkHash: types.ChunkHash(chunk1Hash),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Create(ctx, nil, fc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test DeleteByFileID
|
||||||
|
err = repo.DeleteByFileID(ctx, nil, file.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete file chunks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileChunks, err := repo.GetByFileID(ctx, file.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get deleted file chunks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fileChunks) != 0 {
|
||||||
|
t.Errorf("expected 0 chunks after delete, got %d", len(fileChunks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileChunkRepository(db)
|
||||||
|
fileRepo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testTime := time.Now().Truncate(time.Second)
|
||||||
|
filePaths := []string{testFilePath1, testFilePath2, "/file3.txt"}
|
||||||
|
files := make([]*database.File, len(filePaths))
|
||||||
|
|
||||||
|
for i, path := range filePaths {
|
||||||
|
file := &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: testTime,
|
||||||
|
Size: 2048,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
LinkTarget: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
mustCreateFile(t, fileRepo, file)
|
||||||
|
|
||||||
|
files[i] = file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all chunks first
|
||||||
|
chunkRepo := database.NewChunkRepository(db)
|
||||||
|
|
||||||
|
for i := range files {
|
||||||
|
for j := range 2 {
|
||||||
|
chunkHash := types.ChunkHash(fmt.Sprintf("file%d_chunk%d", i, j))
|
||||||
|
chunk := &database.Chunk{
|
||||||
|
ChunkHash: chunkHash,
|
||||||
|
Size: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := chunkRepo.Create(ctx, nil, chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create chunks for multiple files
|
// Create chunks for multiple files
|
||||||
files := []string{"/file1.txt", "/file2.txt", "/file3.txt"}
|
for i, file := range files {
|
||||||
for _, path := range files {
|
for j := range 2 {
|
||||||
for i := 0; i < 2; i++ {
|
fc := &database.FileChunk{
|
||||||
fc := &FileChunk{
|
FileID: file.ID,
|
||||||
Path: path,
|
Idx: j,
|
||||||
Idx: i,
|
ChunkHash: types.ChunkHash(fmt.Sprintf("file%d_chunk%d", i, j)),
|
||||||
ChunkHash: fmt.Sprintf("%s_chunk%d", path, i),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repo.Create(ctx, nil, fc)
|
err := repo.Create(ctx, nil, fc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create file chunk: %v", err)
|
t.Fatalf("failed to create file chunk: %v", err)
|
||||||
@@ -107,13 +211,14 @@ func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify each file has correct chunks
|
// Verify each file has correct chunks
|
||||||
for _, path := range files {
|
for i, file := range files {
|
||||||
chunks, err := repo.GetByPath(ctx, path)
|
chunks, err := repo.GetByFileID(ctx, file.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get chunks for %s: %v", path, err)
|
t.Fatalf("failed to get chunks for file %d: %v", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunks) != 2 {
|
if len(chunks) != 2 {
|
||||||
t.Errorf("expected 2 chunks for %s, got %d", path, len(chunks))
|
t.Errorf("expected 2 chunks for file %d, got %d", i, len(chunks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+437
-58
@@ -3,130 +3,195 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FileRepository provides access to the files table, which stores file
|
||||||
|
// metadata (path, times, permissions, ownership, symlink targets).
|
||||||
type FileRepository struct {
|
type FileRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewFileRepository creates a FileRepository backed by db.
|
||||||
func NewFileRepository(db *DB) *FileRepository {
|
func NewFileRepository(db *DB) *FileRepository {
|
||||||
return &FileRepository{db: db}
|
return &FileRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create inserts or updates a file row (upsert on path), using tx when
|
||||||
|
// non-nil. The file's ID is generated when zero and updated from the
|
||||||
|
// database's RETURNING clause.
|
||||||
func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) error {
|
func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) error {
|
||||||
|
// Generate UUID if not provided
|
||||||
|
if file.ID.IsZero() {
|
||||||
|
file.ID = types.NewFileID()
|
||||||
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO files (path, mtime, ctime, size, mode, uid, gid, link_target)
|
INSERT INTO files
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
(id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
|
source_path = excluded.source_path,
|
||||||
mtime = excluded.mtime,
|
mtime = excluded.mtime,
|
||||||
ctime = excluded.ctime,
|
mtime_nsec = excluded.mtime_nsec,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
mode = excluded.mode,
|
mode = excluded.mode,
|
||||||
uid = excluded.uid,
|
uid = excluded.uid,
|
||||||
gid = excluded.gid,
|
gid = excluded.gid,
|
||||||
link_target = excluded.link_target
|
link_target = excluded.link_target
|
||||||
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
var err error
|
var (
|
||||||
|
idStr string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, file.Path, file.MTime.Unix(), file.CTime.Unix(), file.Size, file.Mode, file.UID, file.GID, file.LinkTarget)
|
LogSQL("Execute", query,
|
||||||
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||||
|
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||||
|
file.Size, file.Mode, file.UID, file.GID,
|
||||||
|
file.LinkTarget.String())
|
||||||
|
err = tx.QueryRowContext(ctx, query,
|
||||||
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||||
|
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||||
|
file.Size, file.Mode, file.UID, file.GID,
|
||||||
|
file.LinkTarget.String()).Scan(&idStr)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, file.Path, file.MTime.Unix(), file.CTime.Unix(), file.Size, file.Mode, file.UID, file.GID, file.LinkTarget)
|
err = r.db.QueryRowWithLog(ctx, query,
|
||||||
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||||
|
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||||
|
file.Size, file.Mode, file.UID, file.GID,
|
||||||
|
file.LinkTarget.String()).Scan(&idStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inserting file: %w", err)
|
return fmt.Errorf("inserting file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse the returned ID
|
||||||
|
file.ID, err = types.ParseFileID(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing file ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByPath returns the file at the given path, or nil if the path is not
|
||||||
|
// in the index.
|
||||||
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
|
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT path, mtime, ctime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE path = ?
|
WHERE path = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
var file File
|
file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, path))
|
||||||
var mtimeUnix, ctimeUnix int64
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
var linkTarget sql.NullString
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
|
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, path).Scan(
|
|
||||||
&file.Path,
|
|
||||||
&mtimeUnix,
|
|
||||||
&ctimeUnix,
|
|
||||||
&file.Size,
|
|
||||||
&file.Mode,
|
|
||||||
&file.UID,
|
|
||||||
&file.GID,
|
|
||||||
&linkTarget,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying file: %w", err)
|
return nil, fmt.Errorf("querying file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file.MTime = time.Unix(mtimeUnix, 0)
|
return file, nil
|
||||||
file.CTime = time.Unix(ctimeUnix, 0)
|
|
||||||
if linkTarget.Valid {
|
|
||||||
file.LinkTarget = linkTarget.String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &file, nil
|
// GetByID retrieves a file by its UUID
|
||||||
}
|
func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, error) {
|
||||||
|
|
||||||
func (r *FileRepository) ListModifiedSince(ctx context.Context, since time.Time) ([]*File, error) {
|
|
||||||
query := `
|
query := `
|
||||||
SELECT path, mtime, ctime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE mtime >= ?
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, id.String()))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByPathTx returns the file at the given path within a transaction, or
|
||||||
|
// nil if the path is not in the index.
|
||||||
|
func (r *FileRepository) GetByPathTx(
|
||||||
|
ctx context.Context, tx *sql.Tx, path string,
|
||||||
|
) (*File, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
|
FROM files
|
||||||
|
WHERE path = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
LogSQL("GetByPathTx QueryRowContext", query, path)
|
||||||
|
file, err := r.scanFile(tx.QueryRowContext(ctx, query, path))
|
||||||
|
LogSQL("GetByPathTx Scan complete", query, path)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileRowScanner abstracts *sql.Row and *sql.Rows for scanning a file row.
|
||||||
|
type fileRowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModifiedSince returns all files whose recorded mtime is at or after
|
||||||
|
// since, ordered by path.
|
||||||
|
func (r *FileRepository) ListModifiedSince(
|
||||||
|
ctx context.Context, since time.Time,
|
||||||
|
) ([]*File, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
|
FROM files
|
||||||
|
WHERE (mtime, mtime_nsec) >= (?, ?)
|
||||||
ORDER BY path
|
ORDER BY path
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, since.Unix())
|
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||||
|
since.Unix(), since.Nanosecond())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying files: %w", err)
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
}
|
}
|
||||||
defer CloseRows(rows)
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var files []*File
|
var files []*File
|
||||||
for rows.Next() {
|
|
||||||
var file File
|
|
||||||
var mtimeUnix, ctimeUnix int64
|
|
||||||
var linkTarget sql.NullString
|
|
||||||
|
|
||||||
err := rows.Scan(
|
for rows.Next() {
|
||||||
&file.Path,
|
file, err := r.scanFileRows(rows)
|
||||||
&mtimeUnix,
|
|
||||||
&ctimeUnix,
|
|
||||||
&file.Size,
|
|
||||||
&file.Mode,
|
|
||||||
&file.UID,
|
|
||||||
&file.GID,
|
|
||||||
&linkTarget,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning file: %w", err)
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file.MTime = time.Unix(mtimeUnix, 0)
|
files = append(files, file)
|
||||||
file.CTime = time.Unix(ctimeUnix, 0)
|
|
||||||
if linkTarget.Valid {
|
|
||||||
file.LinkTarget = linkTarget.String
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, &file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return files, rows.Err()
|
return files, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the file row at the given path, using tx when non-nil.
|
||||||
func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) error {
|
func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) error {
|
||||||
query := `DELETE FROM files WHERE path = ?`
|
query := `DELETE FROM files WHERE path = ?`
|
||||||
|
|
||||||
@@ -134,7 +199,7 @@ func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) er
|
|||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, path)
|
_, err = tx.ExecContext(ctx, query, path)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLock(ctx, query, path)
|
_, err = r.db.ExecWithLog(ctx, query, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,3 +208,317 @@ func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) er
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteByID deletes a file by its UUID
|
||||||
|
func (r *FileRepository) DeleteByID(
|
||||||
|
ctx context.Context, tx *sql.Tx, id types.FileID,
|
||||||
|
) error {
|
||||||
|
query := `DELETE FROM files WHERE id = ?`
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, id.String())
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnderPath returns the file at path and every file beneath it,
|
||||||
|
// ordered by path. Paths are compared case-sensitively, and a trailing
|
||||||
|
// slash on path is ignored, so "/" lists every file.
|
||||||
|
func (r *FileRepository) ListUnderPath(
|
||||||
|
ctx context.Context, path string,
|
||||||
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
// LIKE would ignore ASCII case and treat _ and % in path as wildcards.
|
||||||
|
query := `
|
||||||
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
|
FROM files
|
||||||
|
WHERE path = ? OR substr(path, 1, length(?)) = ?
|
||||||
|
ORDER BY path
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, path, dirPrefix, dirPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var files []*File
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
file, err := r.scanFileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
|
||||||
|
// for path and that the snapshot lists, in no particular order.
|
||||||
|
func (r *FileRepository) ListUnderPathInSnapshot(
|
||||||
|
ctx context.Context, path, snapshotID string,
|
||||||
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
|
||||||
|
f.mode, f.uid, f.gid, f.link_target
|
||||||
|
FROM files f
|
||||||
|
JOIN snapshot_files sf ON sf.file_id = f.id
|
||||||
|
WHERE sf.snapshot_id = ?
|
||||||
|
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||||
|
snapshotID, path, dirPrefix, dirPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var files []*File
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
file, err := r.scanFileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
||||||
|
// path starts with prefix and that list at least one chunk held by no
|
||||||
|
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
||||||
|
// cannot reference such a chunk, so a backup must not treat the file as
|
||||||
|
// unchanged even when its metadata matches the file on disk.
|
||||||
|
func (r *FileRepository) ListIDsWithChunksNotInUploadedBlobs(
|
||||||
|
ctx context.Context, prefix string,
|
||||||
|
) ([]types.FileID, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT f.id
|
||||||
|
FROM files f
|
||||||
|
JOIN file_chunks fc ON fc.file_id = f.id
|
||||||
|
WHERE f.path LIKE ? || '%'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM blob_chunks bc
|
||||||
|
JOIN blobs b ON bc.blob_id = b.id
|
||||||
|
WHERE bc.chunk_hash = fc.chunk_hash
|
||||||
|
AND b.uploaded_ts IS NOT NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var ids []types.FileID
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id types.FileID
|
||||||
|
|
||||||
|
err := rows.Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll returns all files in the database
|
||||||
|
func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
|
FROM files
|
||||||
|
ORDER BY path
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var files []*File
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
file, err := r.scanFileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBatch inserts or updates multiple files in a single statement for efficiency.
|
||||||
|
// File IDs must be pre-generated before calling this method.
|
||||||
|
func (r *FileRepository) CreateBatch(
|
||||||
|
ctx context.Context, tx *sql.Tx, files []*File,
|
||||||
|
) error {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each files row binds this many SQL variables.
|
||||||
|
const fileCols = 10
|
||||||
|
|
||||||
|
// Batch at 100 rows to be safe with SQLite's variable limit.
|
||||||
|
const batchSize = 100
|
||||||
|
|
||||||
|
for i := 0; i < len(files); i += batchSize {
|
||||||
|
end := min(i+batchSize, len(files))
|
||||||
|
|
||||||
|
batch := files[i:end]
|
||||||
|
|
||||||
|
query := `INSERT INTO files
|
||||||
|
(id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target)
|
||||||
|
VALUES `
|
||||||
|
|
||||||
|
args := make([]any, 0, len(batch)*fileCols)
|
||||||
|
|
||||||
|
var querySb325 strings.Builder
|
||||||
|
|
||||||
|
for j, f := range batch {
|
||||||
|
if j > 0 {
|
||||||
|
querySb325.WriteString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
f.ID.String(), f.Path.String(), f.SourcePath.String(),
|
||||||
|
f.MTime.Unix(), f.MTime.Nanosecond(),
|
||||||
|
f.Size, f.Mode, f.UID, f.GID,
|
||||||
|
f.LinkTarget.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += querySb325.String()
|
||||||
|
|
||||||
|
query += ` ON CONFLICT(path) DO UPDATE SET
|
||||||
|
source_path = excluded.source_path,
|
||||||
|
mtime = excluded.mtime,
|
||||||
|
mtime_nsec = excluded.mtime_nsec,
|
||||||
|
size = excluded.size,
|
||||||
|
mode = excluded.mode,
|
||||||
|
uid = excluded.uid,
|
||||||
|
gid = excluded.gid,
|
||||||
|
link_target = excluded.link_target`
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, args...)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecWithLog(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch inserting files: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrphaned deletes files that are not referenced by any snapshot
|
||||||
|
func (r *FileRepository) DeleteOrphaned(ctx context.Context) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM files
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM snapshot_files
|
||||||
|
WHERE snapshot_files.file_id = files.id
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.ExecWithLog(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting orphaned files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected > 0 {
|
||||||
|
log.Debug("Deleted orphaned files", "count", rowsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanFile is a helper that scans a single file row
|
||||||
|
func (r *FileRepository) scanFile(row *sql.Row) (*File, error) {
|
||||||
|
return r.scanFileFrom(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanFileRows is a helper that scans a file row from rows iterator
|
||||||
|
func (r *FileRepository) scanFileRows(rows *sql.Rows) (*File, error) {
|
||||||
|
return r.scanFileFrom(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanFileFrom scans one file row from any row scanner.
|
||||||
|
func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
||||||
|
var (
|
||||||
|
file File
|
||||||
|
idStr, pathStr, sourcePathStr string
|
||||||
|
mtimeUnix, mtimeNsec int64
|
||||||
|
linkTarget sql.NullString
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&idStr,
|
||||||
|
&pathStr,
|
||||||
|
&sourcePathStr,
|
||||||
|
&mtimeUnix,
|
||||||
|
&mtimeNsec,
|
||||||
|
&file.Size,
|
||||||
|
&file.Mode,
|
||||||
|
&file.UID,
|
||||||
|
&file.GID,
|
||||||
|
&linkTarget,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file.ID, err = types.ParseFileID(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing file ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Path = types.FilePath(pathStr)
|
||||||
|
file.SourcePath = types.SourcePath(sourcePathStr)
|
||||||
|
|
||||||
|
file.MTime = time.Unix(mtimeUnix, mtimeNsec).UTC()
|
||||||
|
if linkTarget.Valid {
|
||||||
|
file.LinkTarget = types.FilePath(linkTarget.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
+317
-44
@@ -1,45 +1,35 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) (*DB, func()) {
|
// errTestRollback is the sentinel returned from transaction bodies to
|
||||||
ctx := context.Background()
|
// force a rollback in tests.
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
var errTestRollback = errors.New("test rollback")
|
||||||
|
|
||||||
db, err := New(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
if err := db.Close(); err != nil {
|
|
||||||
t.Errorf("failed to close database: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return db, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileRepository(t *testing.T) {
|
func TestFileRepository(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewFileRepository(db)
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
file := &File{
|
file := &database.File{
|
||||||
Path: "/test/file.txt",
|
Path: testFileTxt,
|
||||||
MTime: time.Now().Truncate(time.Second),
|
MTime: time.Now().Truncate(time.Second),
|
||||||
CTime: time.Now().Truncate(time.Second),
|
|
||||||
Size: 1024,
|
Size: 1024,
|
||||||
Mode: 0644,
|
Mode: 0644,
|
||||||
UID: 1000,
|
UID: 1000,
|
||||||
@@ -53,22 +43,27 @@ func TestFileRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test GetByPath
|
// Test GetByPath
|
||||||
retrieved, err := repo.GetByPath(ctx, file.Path)
|
retrieved, err := repo.GetByPath(ctx, file.Path.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get file: %v", err)
|
t.Fatalf("failed to get file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved == nil {
|
if retrieved == nil {
|
||||||
t.Fatal("expected file, got nil")
|
t.Fatal("expected file, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved.Path != file.Path {
|
if retrieved.Path != file.Path {
|
||||||
t.Errorf("path mismatch: got %s, want %s", retrieved.Path, file.Path)
|
t.Errorf("path mismatch: got %s, want %s", retrieved.Path, file.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !retrieved.MTime.Equal(file.MTime) {
|
if !retrieved.MTime.Equal(file.MTime) {
|
||||||
t.Errorf("mtime mismatch: got %v, want %v", retrieved.MTime, file.MTime)
|
t.Errorf("mtime mismatch: got %v, want %v", retrieved.MTime, file.MTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved.Size != file.Size {
|
if retrieved.Size != file.Size {
|
||||||
t.Errorf("size mismatch: got %d, want %d", retrieved.Size, file.Size)
|
t.Errorf("size mismatch: got %d, want %d", retrieved.Size, file.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved.Mode != file.Mode {
|
if retrieved.Mode != file.Mode {
|
||||||
t.Errorf("mode mismatch: got %o, want %o", retrieved.Mode, file.Mode)
|
t.Errorf("mode mismatch: got %o, want %o", retrieved.Mode, file.Mode)
|
||||||
}
|
}
|
||||||
@@ -76,55 +71,219 @@ func TestFileRepository(t *testing.T) {
|
|||||||
// Test Update (upsert)
|
// Test Update (upsert)
|
||||||
file.Size = 2048
|
file.Size = 2048
|
||||||
file.MTime = time.Now().Truncate(time.Second)
|
file.MTime = time.Now().Truncate(time.Second)
|
||||||
|
|
||||||
err = repo.Create(ctx, nil, file)
|
err = repo.Create(ctx, nil, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to update file: %v", err)
|
t.Fatalf("failed to update file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieved, err = repo.GetByPath(ctx, file.Path)
|
retrieved, err = repo.GetByPath(ctx, file.Path.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get updated file: %v", err)
|
t.Fatalf("failed to get updated file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved.Size != 2048 {
|
if retrieved.Size != 2048 {
|
||||||
t.Errorf("size not updated: got %d, want %d", retrieved.Size, 2048)
|
t.Errorf("size not updated: got %d, want %d", retrieved.Size, 2048)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRepositoryListDelete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
file := &database.File{
|
||||||
|
Path: testFileTxt,
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Create(ctx, nil, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test ListModifiedSince
|
// Test ListModifiedSince
|
||||||
files, err := repo.ListModifiedSince(ctx, time.Now().Add(-1*time.Hour))
|
files, err := repo.ListModifiedSince(ctx, time.Now().Add(-1*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to list files: %v", err)
|
t.Fatalf("failed to list files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) != 1 {
|
if len(files) != 1 {
|
||||||
t.Errorf("expected 1 file, got %d", len(files))
|
t.Errorf("expected 1 file, got %d", len(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test Delete
|
// Test Delete
|
||||||
err = repo.Delete(ctx, nil, file.Path)
|
err = repo.Delete(ctx, nil, file.Path.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to delete file: %v", err)
|
t.Fatalf("failed to delete file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieved, err = repo.GetByPath(ctx, file.Path)
|
retrieved, err := repo.GetByPath(ctx, file.Path.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error getting deleted file: %v", err)
|
t.Fatalf("error getting deleted file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved != nil {
|
if retrieved != nil {
|
||||||
t.Error("expected nil for deleted file")
|
t.Error("expected nil for deleted file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileRepositorySymlink(t *testing.T) {
|
func TestFileRepositoryListUnderPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := NewFileRepository(db)
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
const (
|
||||||
|
docDir = "/home/u/doc"
|
||||||
|
docFile = "/home/u/doc/a.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In path order, so the root case can expect all of them as listed.
|
||||||
|
paths := []string{
|
||||||
|
"/home/u/50%/x.txt",
|
||||||
|
"/home/u/50percent/y.txt",
|
||||||
|
"/home/u/DOC/c.txt",
|
||||||
|
"/home/u/a_b/x.txt",
|
||||||
|
"/home/u/axb/y.txt",
|
||||||
|
docDir,
|
||||||
|
"/home/u/doc.txt.bak",
|
||||||
|
docFile,
|
||||||
|
"/home/u/doc/sub/b.txt",
|
||||||
|
"/home/u/doc2/b.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := repo.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docTree := []string{docDir, docFile, "/home/u/doc/sub/b.txt"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"directory", docDir, docTree},
|
||||||
|
{"directory with trailing slash", docDir + "/", docTree},
|
||||||
|
{"directory differing only in case", "/home/u/DOC",
|
||||||
|
[]string{"/home/u/DOC/c.txt"}},
|
||||||
|
{"file", docFile, []string{docFile}},
|
||||||
|
{"underscore is literal", "/home/u/a_b",
|
||||||
|
[]string{"/home/u/a_b/x.txt"}},
|
||||||
|
{"percent is literal", "/home/u/50%",
|
||||||
|
[]string{"/home/u/50%/x.txt"}},
|
||||||
|
{"root", "/", paths},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
files, err := repo.ListUnderPath(ctx, tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to list files: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, tt.want) {
|
||||||
|
t.Errorf("%s: listing %q got %q, want %q",
|
||||||
|
tt.name, tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot leaves out files outside path and files the
|
||||||
|
// snapshot does not list, though another snapshot lists them.
|
||||||
|
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
files := database.NewFileRepository(db)
|
||||||
|
snapshots := database.NewSnapshotRepository(db)
|
||||||
|
|
||||||
|
listed := map[string][]string{
|
||||||
|
"one": {"/src/a.txt", "/src2/c.txt"},
|
||||||
|
"two": {"/src/b.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for snapshotID, paths := range listed {
|
||||||
|
err := snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(snapshotID),
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := files.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshots.AddFile(ctx, nil, snapshotID, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(found))
|
||||||
|
for _, f := range found {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"/src/a.txt"}) {
|
||||||
|
t.Errorf("got %q, want only /src/a.txt", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRepositorySymlink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
// Test symlink
|
// Test symlink
|
||||||
symlink := &File{
|
symlink := &database.File{
|
||||||
Path: "/test/link",
|
Path: "/test/link",
|
||||||
MTime: time.Now().Truncate(time.Second),
|
MTime: time.Now().Truncate(time.Second),
|
||||||
CTime: time.Now().Truncate(time.Second),
|
|
||||||
Size: 0,
|
Size: 0,
|
||||||
Mode: uint32(0777 | os.ModeSymlink),
|
Mode: uint32(0777 | os.ModeSymlink),
|
||||||
UID: 1000,
|
UID: 1000,
|
||||||
@@ -137,54 +296,168 @@ func TestFileRepositorySymlink(t *testing.T) {
|
|||||||
t.Fatalf("failed to create symlink: %v", err)
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieved, err := repo.GetByPath(ctx, symlink.Path)
|
retrieved, err := repo.GetByPath(ctx, symlink.Path.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get symlink: %v", err)
|
t.Fatalf("failed to get symlink: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !retrieved.IsSymlink() {
|
if !retrieved.IsSymlink() {
|
||||||
t.Error("expected IsSymlink() to be true")
|
t.Error("expected IsSymlink() to be true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved.LinkTarget != symlink.LinkTarget {
|
if retrieved.LinkTarget != symlink.LinkTarget {
|
||||||
t.Errorf("link target mismatch: got %s, want %s", retrieved.LinkTarget, symlink.LinkTarget)
|
t.Errorf("link target mismatch: got %s, want %s",
|
||||||
|
retrieved.LinkTarget, symlink.LinkTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileRepositoryTransaction(t *testing.T) {
|
// An mtime after 2262 or before 1678 does not fit in int64 nanoseconds
|
||||||
|
// since the epoch, and must still come back from the database unchanged.
|
||||||
|
func TestFileRepositoryMTimeOutsideInt64NanosecondRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
db, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repos := NewRepositories(db)
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
mtimes := []time.Time{
|
||||||
|
time.Date(2300, time.January, 1, 0, 0, 0, 123456789, time.UTC),
|
||||||
|
time.Date(1601, time.January, 1, 0, 0, 0, 987654321, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mtime := range mtimes {
|
||||||
|
created := &database.File{
|
||||||
|
Path: types.FilePath("/created-" + mtime.Format(time.RFC3339Nano)),
|
||||||
|
MTime: mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Create(ctx, nil, created)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batched := &database.File{
|
||||||
|
ID: types.NewFileID(),
|
||||||
|
Path: types.FilePath("/batched-" + mtime.Format(time.RFC3339Nano)),
|
||||||
|
MTime: mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repo.CreateBatch(ctx, nil, []*database.File{batched})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to batch create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []types.FilePath{created.Path, batched.Path} {
|
||||||
|
retrieved, err := repo.GetByPath(ctx, path.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !retrieved.MTime.Equal(mtime) {
|
||||||
|
t.Errorf("%s: mtime got %v, want %v",
|
||||||
|
path, retrieved.MTime, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file already in the index and rewritten within the same second must get
|
||||||
|
// its new nanoseconds stored, through both Create and CreateBatch.
|
||||||
|
func TestFileRepositoryUpsertMTimeInSameSecond(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
indexed := time.Date(2026, time.October, 7, 12, 0, 0, 100000000, time.UTC)
|
||||||
|
rewritten := indexed.Add(800 * time.Millisecond)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
upsert func(file *database.File) error
|
||||||
|
}{
|
||||||
|
{"Create", func(file *database.File) error {
|
||||||
|
return repo.Create(ctx, nil, file)
|
||||||
|
}},
|
||||||
|
{"CreateBatch", func(file *database.File) error {
|
||||||
|
return repo.CreateBatch(ctx, nil, []*database.File{file})
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
file := &database.File{
|
||||||
|
ID: types.NewFileID(),
|
||||||
|
Path: types.FilePath("/" + tt.name),
|
||||||
|
MTime: indexed,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tt.upsert(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to create file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.MTime = rewritten
|
||||||
|
|
||||||
|
err = tt.upsert(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to update file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := repo.GetByPath(ctx, file.Path.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to get file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !retrieved.MTime.Equal(rewritten) {
|
||||||
|
t.Errorf("%s: mtime got %v, want %v",
|
||||||
|
tt.name, retrieved.MTime, rewritten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRepositoryTransaction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
// Test transaction rollback
|
// Test transaction rollback
|
||||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
file := &File{
|
file := &database.File{
|
||||||
Path: "/test/tx_file.txt",
|
Path: testTxFile,
|
||||||
MTime: time.Now().Truncate(time.Second),
|
MTime: time.Now().Truncate(time.Second),
|
||||||
CTime: time.Now().Truncate(time.Second),
|
|
||||||
Size: 1024,
|
Size: 1024,
|
||||||
Mode: 0644,
|
Mode: 0644,
|
||||||
UID: 1000,
|
UID: 1000,
|
||||||
GID: 1000,
|
GID: 1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repos.Files.Create(ctx, tx, file); err != nil {
|
err := repos.Files.Create(ctx, tx, file)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return error to trigger rollback
|
// Return error to trigger rollback
|
||||||
return fmt.Errorf("test rollback")
|
return errTestRollback
|
||||||
})
|
})
|
||||||
|
if !errors.Is(err, errTestRollback) {
|
||||||
if err == nil || err.Error() != "test rollback" {
|
|
||||||
t.Fatalf("expected rollback error, got: %v", err)
|
t.Fatalf("expected rollback error, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file was not created
|
// Verify file was not created
|
||||||
retrieved, err := repos.Files.GetByPath(ctx, "/test/tx_file.txt")
|
retrieved, err := repos.Files.GetByPath(ctx, testTxFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error checking for file: %v", err)
|
t.Fatalf("error checking for file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if retrieved != nil {
|
if retrieved != nil {
|
||||||
t.Error("file should not exist after rollback")
|
t.Error("file should not exist after rollback")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common fixture values shared by the internal repository tests.
|
||||||
|
const (
|
||||||
|
internalTestHost = "test-host"
|
||||||
|
internalTestSnapshotID = "test-snapshot"
|
||||||
|
internalTestFilePath = "/test.txt"
|
||||||
|
internalTestFile1 = "/file1.txt"
|
||||||
|
internalTestFile2 = "/file2.txt"
|
||||||
|
|
||||||
|
// countFilesQuery counts the rows of the files table.
|
||||||
|
countFilesQuery = "SELECT COUNT(*) FROM files"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustCreateFileRow inserts the file row, failing the test on error.
|
||||||
|
func mustCreateFileRow(t *testing.T, repos *Repositories, file *File) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := repos.Files.Create(context.Background(), nil, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file %s: %v", file.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustAddFileToSnapshot associates a file with a snapshot, failing the
|
||||||
|
// test on error.
|
||||||
|
func mustAddFileToSnapshot(
|
||||||
|
t *testing.T, repos *Repositories, snapshotID string, fileID types.FileID,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := repos.Snapshots.AddFileByID(context.Background(), nil, snapshotID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestDB creates an on-disk test database in a per-test temp
|
||||||
|
// directory and returns it along with a cleanup func that closes it.
|
||||||
|
func setupTestDB(t *testing.T) (*DB, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
|
||||||
|
db, err := New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to close database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRow runs a single-integer COUNT-style query and returns the value.
|
||||||
|
func countRow(t *testing.T, db *DB, query string, args ...any) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.conn.QueryRowContext(context.Background(), query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common fixture values shared by the repository tests.
|
||||||
|
const (
|
||||||
|
testFilePath1 = "/file1.txt"
|
||||||
|
testFilePath2 = "/file2.txt"
|
||||||
|
testFileTxt = "/test/file.txt"
|
||||||
|
testTxFile = "/test/tx_file.txt"
|
||||||
|
testHostname = "test-host"
|
||||||
|
testVersion = "1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustCreateFile inserts the given file row, failing the test on error.
|
||||||
|
func mustCreateFile(t *testing.T, repo *database.FileRepository, file *database.File) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := repo.Create(context.Background(), nil, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file %s: %v", file.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestDB creates an on-disk test database in a per-test temp
|
||||||
|
// directory and returns it along with a cleanup func that closes it.
|
||||||
|
func setupTestDB(t *testing.T) (*database.DB, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to close database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cleanup
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalMetaKeyStorageURL is the key under which the destination store's
|
||||||
|
// URL is recorded when a mutating command first binds the local index
|
||||||
|
// to a specific backup destination.
|
||||||
|
const LocalMetaKeyStorageURL = "storage_url"
|
||||||
|
|
||||||
|
// LocalMetaRepository provides keyed access to host-local settings
|
||||||
|
// stored in the local_meta table.
|
||||||
|
type LocalMetaRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalMetaRepository creates a LocalMetaRepository backed by db.
|
||||||
|
func NewLocalMetaRepository(db *DB) *LocalMetaRepository {
|
||||||
|
return &LocalMetaRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the value stored at key, or the empty string if the key
|
||||||
|
// is not set. A missing key is not an error — the caller distinguishes
|
||||||
|
// "unset" (bind on first use) from "set to something" (compare).
|
||||||
|
func (r *LocalMetaRepository) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx,
|
||||||
|
"SELECT value FROM local_meta WHERE key = ?", key,
|
||||||
|
).Scan(&value)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading local_meta %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set writes key=value, replacing any prior value.
|
||||||
|
func (r *LocalMetaRepository) Set(ctx context.Context, key, value string) error {
|
||||||
|
_, err := r.db.ExecWithLog(ctx,
|
||||||
|
`INSERT INTO local_meta (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
key, value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing local_meta %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user