2 Commits
Author SHA1 Message Date
sneak d98b1a55db Re-vendor the canonical files from sneak/prompts at dd4027b (closes #213)
check / check (push) Successful in 18m13s
Linting and testing become the lint and test phases of the Dockerfile,
and the build stage depends on both. Dockerfile.lint, CHECK_EPOCH and
the tests that checked them are removed. Every docker build in script/
passes --no-cache, and script/cibuild runs script/bootstrap first. A
host without Go gets the go.mod version from script/install-go in
.tool/go, where script/fmt and script/fmt-check also look; fmt-check
reads only the Go files git lists. The image takes its version from the
VERSION build arg or git describe, dev without .git. This repo's own
entries follow the canonical content in .gitignore and .editorconfig.
The golangci-lint v2.14.0 findings are fixed. The rules in CLAUDE.md
move into AGENTS.md. IsDevVersion counts "unknown".

Model: opus-5-5
2026-10-06 04:27:37 +00:00
clawbot 713be502bd Reject a duration with characters outside its parts (closes #215)
check / check (push) Successful in 7m28s
check / check (pull_request) Successful in 5m39s
parseDuration fell back to an unanchored search for number-and-unit
pieces when time.ParseDuration failed, and skipped everything in
between. 1.0y became 0, so `snapshot create --prune --keep-newer-than
1.0y` deleted every snapshot of the backed-up names, the new one
included. 2.1w became one week and 1,5y five years. The fallback now
requires the whole input to be whole-number-and-unit parts with nothing
between them. A bare number is rejected before time.ParseDuration sees
it, since Go reads 0 and +0 as zero with no unit.

Judgement call: a space between number and unit (`30 days`) was
accepted and is now an error, matching Go's own units.

Model: opus-5-5
2026-10-06 06:12:07 +02:00
10 changed files with 82 additions and 40 deletions
+4 -4
View File
@@ -18,10 +18,10 @@ jobs:
# `before:` hook and for every one of the four cross-compiles. # `before:` hook and for every one of the four cross-compiles.
# Without this step the release either fails at the before-hook or, # Without this step the release either fails at the before-hook or,
# worse, ships binaries built by whatever Go the runner happens to # worse, ships binaries built by whatever Go the runner happens to
# carry. check.yml's script/cibuild also puts Go on its own runner, # carry. check.yml's runner gets the same Go through
# through script/bootstrap from the package manager at whatever # script/bootstrap, which calls script/install-go, and uses it only
# version that ships, but uses it only for `go mod download` and # for `go mod download` and gofmt; it compiles inside the
# gofmt; it compiles inside the digest-pinned Dockerfile images. # digest-pinned Dockerfile images.
# #
# actions/setup-go would pin the action by commit sha, but the Go # actions/setup-go would pin the action by commit sha, but the Go
# tarball it downloads at runtime is verified against no value in # tarball it downloads at runtime is verified against no value in
+9 -7
View File
@@ -763,9 +763,11 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call development workflow, and the Makefile targets are thin shims that call
them. We provide: them. We provide:
* `script/bootstrap` — install all development dependencies (go, Go * `script/bootstrap` — install all development dependencies (Go, Go
module download). It deliberately does not install `golangci-lint`; module download). A host without Go gets the `go.mod` version through
see `script/lint` below. `script/install-go`, in `.tool/go`, which `script/fmt` and
`script/fmt-check` also look in. It deliberately does not install
`golangci-lint`; see `script/lint` below.
* `script/setup` — make a fresh clone ready for development: runs * `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
* `script/projectname` — print the project name (used for the Docker * `script/projectname` — print the project name (used for the Docker
@@ -780,9 +782,9 @@ them. We provide:
`script/bootstrap` insists on. `script/bootstrap` insists on.
* `script/install-go` — install the Go toolchain named by `go.mod`'s * `script/install-go` — install the Go toolchain named by `go.mod`'s
`go` directive into `.tool/go` from a sha256-verified `go.dev` `go` directive into `.tool/go` from a sha256-verified `go.dev`
archive, and put it on `PATH`. Idempotent. Called only by the release archive, and put it on `PATH`. Idempotent. Called by the release
workflow, which needs a host Go for `goreleaser` to shell out to; workflow, which needs a host Go for `goreleaser` to shell out to, and
nothing else on the release runner does. `actions/setup-go` is not by `script/bootstrap` on a host without Go. `actions/setup-go` is not
used because it verifies the downloaded toolchain against no value in used because it verifies the downloaded toolchain against no value in
this repo. Bumping Go edits `go.mod`, the checksum in this script, and this repo. Bumping Go edits `go.mod`, the checksum in this script, and
the two `golang` digests in the `Dockerfile` together. the two `golang` digests in the `Dockerfile` together.
@@ -813,7 +815,7 @@ them. We provide:
find out whether the tree is clean. find out whether the tree is clean.
* `script/fmt` — format all code (writes) * `script/fmt` — format all code (writes)
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on * `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
the host. the host, over the Go files git lists.
* `script/check` — run `script/test`, `script/lint`, and * `script/check` — run `script/test`, `script/lint`, and
`script/fmt-check`. `script/fmt-check`.
* `script/docker` — build the Docker image tagged via * `script/docker` — build the Docker image tagged via
+9
View File
@@ -31,6 +31,15 @@ the tag exists and is exercised; what is left is merging `next` to
is v2.14.0, with its new findings fixed in the code, and the rules in is v2.14.0, with its new findings fixed in the code, and the rules in
`CLAUDE.md` now live in `AGENTS.md`. `CLAUDE.md` now live in `AGENTS.md`.
- 2026-10-06: Made `--older-than` and `--keep-newer-than` reject a
duration with characters outside its number-and-unit parts
([issue #215](https://git.eeqj.de/sneak/vaultik/issues/215)). The
parser picked out the parts it recognised and skipped the rest, so
`1.0y` became zero and `--prune --keep-newer-than 1.0y` deleted every
snapshot of the backed-up names, the new one included. `1.0y`,
`1,5y`, `30 days`, `x7d` and a bare `0` are now errors; decimals still
work in Go units such as `1.5h`.
- 2026-10-06: Made a backup re-chunk a known file that lists a chunk no - 2026-10-06: Made a backup re-chunk a known file that lists a chunk no
uploaded blob holds uploaded blob holds
([issue #214](https://git.eeqj.de/sneak/vaultik/issues/214)). File ([issue #214](https://git.eeqj.de/sneak/vaultik/issues/214)). File
+10 -6
View File
@@ -18,14 +18,19 @@ var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build t
// deliberately not a number. // deliberately not a number.
const DevVersion = "dev" const DevVersion = "dev"
// Unknown is what Commit and CommitDate hold when the build did not
// stamp them, and the version script/docker and script/cibuild stamp
// when the host has no git checkout.
const Unknown = "unknown"
// Version is the application version, populated from main(). // Version is the application version, populated from main().
var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time
// Commit is the git commit hash, populated from main(). // Commit is the git commit hash, populated from main().
var Commit = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time var Commit = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
// CommitDate is the ISO-8601 date of the commit, populated from main(). // CommitDate is the ISO-8601 date of the commit, populated from main().
var CommitDate = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
// Author identifies the upstream author of vaultik. // Author identifies the upstream author of vaultik.
const Author = "Jeffrey Paul <sneak@sneak.berlin>" const Author = "Jeffrey Paul <sneak@sneak.berlin>"
@@ -72,11 +77,10 @@ func New() (*Globals, error) {
// safe reading of "we could not establish that this is a release" is // safe reading of "we could not establish that this is a release" is
// that it is not one. The Makefile refuses to build at all in that // that it is not one. The Makefile refuses to build at all in that
// case; this is the second line of defence, for a binary linked by // case; this is the second line of defence, for a binary linked by
// something other than the Makefile. "unknown" counts for the same // something other than the Makefile. Unknown counts for the same
// reason: script/docker and script/cibuild stamp it when the host has // reason.
// no git checkout.
func IsDevVersion(v string) bool { func IsDevVersion(v string) bool {
if v == "" || v == "unknown" || v == DevVersion || if v == "" || v == Unknown || v == DevVersion ||
strings.HasPrefix(v, DevVersion+"-") || strings.HasPrefix(v, DevVersion+"-") ||
strings.HasSuffix(v, "-dirty") { strings.HasSuffix(v, "-dirty") {
return true return true
+15 -5
View File
@@ -140,24 +140,34 @@ func parseSnapshotName(snapshotID string) string {
// parseDuration parses a duration string with support for human-friendly units: // parseDuration parses a duration string with support for human-friendly units:
// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go // d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go
// duration units. Following Go, m is minutes and mo is months. A bare number, // duration units. Following Go, m is minutes and mo is months. A bare number,
// an unknown unit, and a negative value are all rejected. // an unknown unit, and a negative value are all rejected. Outside the Go
// units the input must be whole numbers each followed directly by a unit
// (2w3d), so 1.0y and 30 days are errors; decimals work only in Go units
// (1.5h).
func parseDuration(s string) (time.Duration, error) { func parseDuration(s string) (time.Duration, error) {
if strings.HasPrefix(strings.TrimSpace(s), "-") { if strings.HasPrefix(strings.TrimSpace(s), "-") {
return 0, errNegativeDuration return 0, errNegativeDuration
} }
// A bare number has no unit, but time.ParseDuration accepts 0, which
// would put the cutoff at now and select every snapshot.
_, err := strconv.ParseFloat(s, 64)
if err == nil {
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
}
d, err := time.ParseDuration(s) d, err := time.ParseDuration(s)
if err == nil { if err == nil {
return d, nil return d, nil
} }
re := regexp.MustCompile(`(\d+)\s*([a-zA-Z]+)`) if !regexp.MustCompile(`^(\d+[a-zA-Z]+)+$`).MatchString(s) {
matches := re.FindAllStringSubmatch(s, -1)
if len(matches) == 0 {
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s) return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
} }
re := regexp.MustCompile(`(\d+)([a-zA-Z]+)`)
matches := re.FindAllStringSubmatch(s, -1)
var total time.Duration var total time.Duration
for _, match := range matches { for _, match := range matches {
+11
View File
@@ -59,6 +59,7 @@ func TestParseDuration(t *testing.T) {
{"30s", 30 * time.Second, false}, {"30s", 30 * time.Second, false},
{"6m", 6 * time.Minute, false}, {"6m", 6 * time.Minute, false},
{"1h", time.Hour, false}, {"1h", time.Hour, false},
{"1.5h", 90 * time.Minute, false},
// Extended calendar units. // Extended calendar units.
{"30d", 30 * 24 * time.Hour, false}, {"30d", 30 * 24 * time.Hour, false},
{"3days", 3 * 24 * time.Hour, false}, {"3days", 3 * 24 * time.Hour, false},
@@ -73,11 +74,21 @@ func TestParseDuration(t *testing.T) {
{"1y6mo", 365*24*time.Hour + 180*24*time.Hour, false}, {"1y6mo", 365*24*time.Hour + 180*24*time.Hour, false},
// Rejected inputs. // Rejected inputs.
{"6", 0, true}, // bare number, no unit {"6", 0, true}, // bare number, no unit
{"0", 0, true}, // bare number; time.ParseDuration accepts it
{"+0", 0, true}, // bare number; time.ParseDuration accepts it
{"5x", 0, true}, // unknown unit {"5x", 0, true}, // unknown unit
{"-5d", 0, true}, // negative, extended unit {"-5d", 0, true}, // negative, extended unit
{"-5h", 0, true}, // negative, Go unit {"-5h", 0, true}, // negative, Go unit
{"", 0, true}, // empty {"", 0, true}, // empty
{"garbage", 0, true}, {"garbage", 0, true},
// Characters outside the number-and-unit parts must not be
// skipped: 1.0y read as 0y would select every snapshot.
{"1.0y", 0, true},
{"2.1w", 0, true},
{"1,5y", 0, true},
{"30 days", 0, true},
{"30 days ago", 0, true},
{"x7d", 0, true},
} }
for _, tt := range tests { for _, tt := range tests {
+8 -8
View File
@@ -38,12 +38,7 @@ pkg_install() {
detect_pkgmgr detect_pkgmgr
case "$PKGMGR" in case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;; nix) nix-env -iA "nixpkgs.$1" ;;
# A fresh image, such as the CI runner's, has no package lists, apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
# so apt-get install finds nothing until they are fetched.
apt)
$SUDO apt-get update
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;; brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;; apk) apk add --no-cache "$4" ;;
esac esac
@@ -109,8 +104,13 @@ main() {
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# Go toolchain # Go toolchain: the host's own, or else the version go.mod names,
if missing go; then pkg_install go golang go go; fi # hash-verified, in .tool/go. That directory is not on the caller's
# PATH, so script/fmt and script/fmt-check look there too.
if missing go; then
"$ROOT/script/install-go"
PATH="$PATH:$ROOT/.tool/go/bin"
fi
# golangci-lint is deliberately NOT installed: script/lint lints by # golangci-lint is deliberately NOT installed: script/lint lints by
# building the lint phase of the Dockerfile, whose digest-pinned # building the lint phase of the Dockerfile, whose digest-pinned
+2
View File
@@ -6,6 +6,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Where script/bootstrap installs Go when the host has none.
PATH="$PATH:$ROOT/.tool/go/bin"
go fmt ./... go fmt ./...
} }
+7 -1
View File
@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
unformatted="$(gofmt -l .)" # Where script/bootstrap installs Go when the host has none.
PATH="$PATH:$ROOT/.tool/go/bin"
# The Go files git lists, which leaves out the sources of that
# toolchain in the ignored .tool/go.
files="$(git ls-files --cached --others --exclude-standard '*.go')"
# shellcheck disable=SC2086 # one argument per file name
unformatted="$(gofmt -l $files)"
if [ -n "$unformatted" ]; then if [ -n "$unformatted" ]; then
echo "Files not formatted:" >&2 echo "Files not formatted:" >&2
echo "$unformatted" >&2 echo "$unformatted" >&2
+7 -9
View File
@@ -4,14 +4,13 @@
# own extension to scripts-to-rule-them-all. Idempotent: exits at once # own extension to scripts-to-rule-them-all. Idempotent: exits at once
# when the pinned toolchain is already installed. # when the pinned toolchain is already installed.
# #
# Only .gitea/workflows/release.yml calls this. goreleaser is not a # .gitea/workflows/release.yml calls this, and so does script/bootstrap
# when the host has no Go, as on the check runner. goreleaser is not a
# compiler: it shells out to `go` for the `before:` hook and for every # compiler: it shells out to `go` for the `before:` hook and for every
# one of the four cross-compiles, so the release runner needs a Go # one of the four cross-compiles, so the release runner needs a Go
# toolchain on PATH. check.yml compiles nothing on the host: it uses # toolchain on PATH. The check runner compiles nothing on the host; it
# whatever Go script/bootstrap finds or installs only for that script's # uses this Go only for bootstrap's `go mod download` and for gofmt in
# `go mod download` and for gofmt in script/fmt-check. So this is the # script/fmt-check. Per REPO_POLICIES.md a host Go is pinned by hash.
# only host Go that builds anything, and per REPO_POLICIES.md it must be
# pinned by hash.
# actions/setup-go exposes no checksum input, so Go is installed the way # actions/setup-go exposes no checksum input, so Go is installed the way
# script/install-goreleaser installs goreleaser: download the exact # script/install-goreleaser installs goreleaser: download the exact
# archive from go.dev and refuse it unless its sha256 matches the value # archive from go.dev and refuse it unless its sha256 matches the value
@@ -23,9 +22,8 @@
# go.mod, the checksum here, and the Dockerfile's two golang digests # go.mod, the checksum here, and the Dockerfile's two golang digests
# together. # together.
# #
# Linux only, because that is what the release runner is. A darwin dev # Linux only, because that is what both runners are. A darwin dev uses
# building a snapshot uses their own Go; supporting an OS means adding # their own Go; supporting an OS means adding its checksums.
# its checksums.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"