Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aed479650 | ||
|
|
1535c09da5 | ||
|
|
ae0e96eba3 | ||
|
|
4f7459d509 | ||
|
|
b19dff2456 | ||
|
|
f310001d1e | ||
|
|
950dd50c2e |
+3
-81
@@ -1,81 +1,3 @@
|
|||||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
*.tmp
|
||||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
*.dockerimage
|
||||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
.git
|
||||||
# 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 binary (make build).
|
|
||||||
/bin/mfer
|
|
||||||
|
|
||||||
# The tools script/bootstrap installs for script/gofumpt and
|
|
||||||
# script/generate.
|
|
||||||
/bin/gofumpt
|
|
||||||
/bin/protoc
|
|
||||||
/bin/protoc-gen-go
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
name: check
|
name: check
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, next]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, next]
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13894f8d5 # v4
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- name: Build Docker image (runs make check internally)
|
||||||
- run: script/cibuild
|
run: docker build .
|
||||||
|
|||||||
+9
-62
@@ -1,63 +1,10 @@
|
|||||||
# OS
|
/bin/
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# 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 artifacts.
|
|
||||||
*.log
|
|
||||||
*.out
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# This repo's own binary (make build), and the tools script/bootstrap
|
|
||||||
# installs into bin/.
|
|
||||||
/bin/mfer
|
|
||||||
/bin/gofumpt
|
|
||||||
/bin/protoc/
|
|
||||||
/bin/protoc-gen-go
|
|
||||||
|
|
||||||
# A manifest generated at the repo root, and local scratch.
|
|
||||||
/index.mf
|
|
||||||
/tmp
|
/tmp
|
||||||
|
*.tmp
|
||||||
|
*.dockerimage
|
||||||
|
/vendor
|
||||||
|
vendor.tzst
|
||||||
|
modcache.tzst
|
||||||
|
|
||||||
|
# Stale files
|
||||||
|
.drone.yml
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
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.
|
|
||||||
# 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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
yarn.lock
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"tabWidth": 4,
|
|
||||||
"proseWrap": "always"
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Agent Instructions
|
|
||||||
|
|
||||||
Read `REPO_POLICIES.md` before making any changes. It is the authoritative
|
|
||||||
source for coding standards, formatting, linting, and workflow rules.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
- When fixing a bug, write a failing test FIRST. Only after the test fails,
|
|
||||||
write the code to fix the bug. Then ensure the test passes. Leave the test in
|
|
||||||
place and commit it with the bugfix. Don't run shell commands to test bugfixes
|
|
||||||
or reproduce bugs. Write tests!
|
|
||||||
|
|
||||||
- After each change, run `make fmt`, then `make test`, then `make lint`. Fix any
|
|
||||||
failures before committing.
|
|
||||||
|
|
||||||
- After each change, commit only the files you've changed. Push after
|
|
||||||
committing.
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
- Never mention Claude, Anthropic, or any AI/LLM tooling in commit messages. Do
|
|
||||||
not use attribution.
|
|
||||||
|
|
||||||
## Repository-Specific Notes
|
|
||||||
|
|
||||||
- This is a Go library + CLI tool for generating `.mf` manifest files.
|
|
||||||
- The proto definition is in `mfer/mf.proto`; generated `.pb.go` files are
|
|
||||||
committed (required for `go get` compatibility).
|
|
||||||
- The format specification is in `docs/FORMAT.md`.
|
|
||||||
- Open work, open design questions included, is tracked only in the repo's
|
|
||||||
issues: https://git.eeqj.de/sneak/mfer/issues. There is no `TODO.md` and no
|
|
||||||
TODO list in `README.md`; do not add either. For this repo this overrides the
|
|
||||||
`REPO_POLICIES.md` rule to put the todo list in the README, per sneak's
|
|
||||||
ruling: https://git.eeqj.de/sneak/mfer/issues/76#issuecomment-118130.
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Important Rules
|
||||||
|
|
||||||
|
- when fixing a bug, write a failing test FIRST. only after the test fails, write
|
||||||
|
the code to fix the bug. then ensure the test passes. leave the test in
|
||||||
|
place and commit it with the bugfix. don't run shell commands to test
|
||||||
|
bugfixes or reproduce bugs. write tests!
|
||||||
|
|
||||||
|
- never, ever mention claude or anthropic in commit messages. do not use attribution
|
||||||
|
|
||||||
|
- after each change, run "make fmt".
|
||||||
|
|
||||||
|
- after each change, run "make test" and ensure all tests pass.
|
||||||
|
|
||||||
|
- after each change, run "make lint" and ensure no linting errors. fix any
|
||||||
|
you find, one by one.
|
||||||
|
|
||||||
|
- after each change, commit the files you've changed. push after
|
||||||
|
committing.
|
||||||
|
|
||||||
|
- NEVER use `git add -A`. always add only individual files that you've changed.
|
||||||
+35
-73
@@ -1,76 +1,38 @@
|
|||||||
# Lint phase. The linter is invoked directly rather than through `make
|
################################################################################
|
||||||
# lint` or `script/lint`, which are themselves a docker build and would
|
#2345678911234567892123456789312345678941234567895123456789612345678971234567898
|
||||||
# recurse into a daemon that does not exist in a build step.
|
################################################################################
|
||||||
# golangci/golangci-lint:v2.14.0, 2026-09-24
|
FROM sneak/builder:2022-12-08 AS builder
|
||||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
ENV DEBIAN_FRONTEND noninteractive
|
||||||
WORKDIR /src
|
WORKDIR /build
|
||||||
COPY go.mod go.sum ./
|
COPY ./Makefile ./.golangci.yml ./go.mod ./go.sum /build/
|
||||||
RUN go mod download
|
COPY ./vendor.tzst /build/vendor.tzst
|
||||||
COPY . .
|
COPY ./modcache.tzst /build/modcache.tzst
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
COPY ./internal ./internal
|
||||||
|
COPY ./bin/gitrev.sh ./bin/gitrev.sh
|
||||||
|
COPY ./mfer ./mfer
|
||||||
|
COPY ./cmd ./cmd
|
||||||
|
ARG GITREV unknown
|
||||||
|
ARG DRONE_COMMIT_SHA unknown
|
||||||
|
|
||||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
RUN mkdir -p "$(go env GOMODCACHE)" && cd "$(go env GOMODCACHE)" && \
|
||||||
# image ships and the alpine one does not.
|
zstdmt -d --stdout /build/modcache.tzst | tar xf - && \
|
||||||
# golang:1.27.1, 2026-10-06
|
rm /build/modcache.tzst && cd /build
|
||||||
FROM golang@sha256:1e93e00a31255c07e9a34c4207f3006e1501730c5323697cee7dfb827fdae44c AS test
|
RUN make check
|
||||||
WORKDIR /src
|
RUN \
|
||||||
COPY go.mod go.sum ./
|
cd mfer && go generate . && cd .. && \
|
||||||
RUN go mod download
|
GOPACKAGESDEBUG=true golangci-lint run ./... && \
|
||||||
COPY . .
|
mkdir vendor && cd vendor && \
|
||||||
RUN go test -timeout 90s -race -cover ./... || \
|
zstdmt -d --stdout /build/vendor.tzst | tar xf - && rm /build/vendor.tzst && \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
cd .. && \
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
make mfer.cmd
|
||||||
|
RUN rm -rf /build/vendor && go mod vendor && tar -c . | zstdmt -19 > /src.tzst
|
||||||
# Vulnerability check, built only by script/vulncheck (make vulncheck).
|
################################################################################
|
||||||
# No stage depends on it, so the image build does not run it.
|
#2345678911234567892123456789312345678941234567895123456789612345678971234567898
|
||||||
# golang:1.27.1, 2026-10-06
|
################################################################################
|
||||||
FROM golang@sha256:1e93e00a31255c07e9a34c4207f3006e1501730c5323697cee7dfb827fdae44c AS vulncheck
|
## final image
|
||||||
# govulncheck v1.8.0, pinned to the commit its release tag names.
|
################################################################################
|
||||||
RUN go install golang.org/x/vuln/cmd/govulncheck@709015412431dd2b5b28a53c06c70bc02d49074c
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN govulncheck ./...
|
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from the lint or test phase; the copies
|
|
||||||
# are what make BuildKit build them first, so this stage cannot run
|
|
||||||
# unless lint and test passed. The Debian Go image ships git, which the
|
|
||||||
# version step below needs.
|
|
||||||
# golang:1.27.1, 2026-10-06
|
|
||||||
FROM golang@sha256:1e93e00a31255c07e9a34c4207f3006e1501730c5323697cee7dfb827fdae44c AS builder
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
COPY --from=test /src/go.sum /dev/null
|
|
||||||
# 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 revision `mfer version` prints, stamped into main.Gitrev: 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.Gitrev=${VERSION}" \
|
|
||||||
-o /mfer ./cmd/mfer/
|
|
||||||
|
|
||||||
# Fail unless /mfer is statically linked: scratch has no C library to run it.
|
|
||||||
RUN ldd /mfer 2>&1 | grep -q 'not a dynamic executable'
|
|
||||||
|
|
||||||
# Runtime stage, and the last one.
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
# scratch has no CA certificates; fetch needs them to verify HTTPS servers.
|
# we put all the source into the final image for posterity, it's small
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /src.tzst /src.tzst
|
||||||
COPY --from=builder /mfer /mfer
|
COPY --from=builder /build/mfer.cmd /mfer
|
||||||
ENTRYPOINT ["/mfer"]
|
ENTRYPOINT ["/mfer"]
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# .mf File Format Specification
|
||||||
|
|
||||||
|
Version 1.0
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
An `.mf` file is a binary manifest that describes a directory tree of files,
|
||||||
|
including their paths, sizes, and cryptographic checksums. It supports
|
||||||
|
optional GPG signatures for integrity verification and optional timestamps
|
||||||
|
for metadata preservation.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
An `.mf` file consists of two parts, concatenated:
|
||||||
|
|
||||||
|
1. **Magic bytes** (8 bytes): the ASCII string `ZNAVSRFG`
|
||||||
|
2. **Outer message**: a Protocol Buffers serialized `MFFileOuter` message
|
||||||
|
|
||||||
|
There is no length prefix or version byte between the magic and the protobuf
|
||||||
|
message. The protobuf message extends to the end of the file.
|
||||||
|
|
||||||
|
See [`mfer/mf.proto`](mfer/mf.proto) for exact field numbers and types.
|
||||||
|
|
||||||
|
## Outer Message (`MFFileOuter`)
|
||||||
|
|
||||||
|
The outer message contains:
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
|--------------------|--------|-------------------|--------------------------------------------------|
|
||||||
|
| `version` | 101 | enum | Must be `VERSION_ONE` (1) |
|
||||||
|
| `compressionType` | 102 | enum | Compression of `innerMessage`; must be `COMPRESSION_ZSTD` (1) |
|
||||||
|
| `size` | 103 | int64 | Uncompressed size of `innerMessage` (corruption detection) |
|
||||||
|
| `sha256` | 104 | bytes | SHA-256 hash of the **compressed** `innerMessage` (corruption detection) |
|
||||||
|
| `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID |
|
||||||
|
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
|
||||||
|
| `signature` | 201 | bytes (optional) | GPG signature (ASCII-armored or binary) |
|
||||||
|
| `signer` | 202 | bytes (optional) | Full GPG key ID of the signer |
|
||||||
|
| `signingPubKey` | 203 | bytes (optional) | Full GPG signing public key |
|
||||||
|
|
||||||
|
### SHA-256 Hash
|
||||||
|
|
||||||
|
The `sha256` field (104) covers the **compressed** `innerMessage` bytes.
|
||||||
|
This allows verifying data integrity before decompression.
|
||||||
|
|
||||||
|
## Compression
|
||||||
|
|
||||||
|
The `innerMessage` field is compressed with [Zstandard (zstd)](https://facebook.github.io/zstd/).
|
||||||
|
Implementations must enforce a decompression size limit to prevent
|
||||||
|
decompression bombs. The reference implementation limits decompressed size to
|
||||||
|
256 MB.
|
||||||
|
|
||||||
|
## Inner Message (`MFFile`)
|
||||||
|
|
||||||
|
After decompressing `innerMessage`, the result is a serialized `MFFile`
|
||||||
|
(referred to as the manifest):
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
|-------------|--------|-----------------------|--------------------------------------------|
|
||||||
|
| `version` | 100 | enum | Must be `VERSION_ONE` (1) |
|
||||||
|
| `files` | 101 | repeated `MFFilePath` | List of files in the manifest |
|
||||||
|
| `uuid` | 102 | bytes | Random v4 UUID; must match outer UUID |
|
||||||
|
| `createdAt` | 201 | Timestamp (optional) | When the manifest was created |
|
||||||
|
|
||||||
|
## File Entries (`MFFilePath`)
|
||||||
|
|
||||||
|
Each file entry contains:
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
|------------|--------|---------------------------|--------------------------------------|
|
||||||
|
| `path` | 1 | string | Relative file path (see Path Rules) |
|
||||||
|
| `size` | 2 | int64 | File size in bytes |
|
||||||
|
| `hashes` | 3 | repeated `MFFileChecksum` | At least one hash required |
|
||||||
|
| `mimeType` | 301 | string (optional) | MIME type |
|
||||||
|
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
||||||
|
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
||||||
|
|
||||||
|
Field 304 (`atime`) has been removed from the specification. Access time is
|
||||||
|
volatile and non-deterministic; it is not useful for integrity verification.
|
||||||
|
|
||||||
|
## Path Rules
|
||||||
|
|
||||||
|
All `path` values must satisfy these invariants:
|
||||||
|
|
||||||
|
- **UTF-8**: paths must be valid UTF-8
|
||||||
|
- **Forward slashes**: use `/` as the path separator (never `\`)
|
||||||
|
- **Relative only**: no leading `/`
|
||||||
|
- **No parent traversal**: no `..` path segments
|
||||||
|
- **No empty segments**: no `//` sequences
|
||||||
|
- **No trailing slash**: paths refer to files, not directories
|
||||||
|
|
||||||
|
Implementations must validate these invariants when reading and writing
|
||||||
|
manifests. Paths that violate these rules must be rejected.
|
||||||
|
|
||||||
|
## Hash Format (`MFFileChecksum`)
|
||||||
|
|
||||||
|
Each checksum is a single `bytes multiHash` field containing a
|
||||||
|
[multihash](https://multiformats.io/multihash/)-encoded value. Multihash is
|
||||||
|
self-describing: the encoded bytes include a varint algorithm identifier
|
||||||
|
followed by a varint digest length followed by the digest itself.
|
||||||
|
|
||||||
|
The 1.0 implementation writes SHA-256 multihashes (`0x12` algorithm code).
|
||||||
|
Implementations must be able to verify SHA-256 multihashes at minimum.
|
||||||
|
|
||||||
|
## Signature Scheme
|
||||||
|
|
||||||
|
Signing is optional. When present, the signature covers a canonical string
|
||||||
|
constructed as:
|
||||||
|
|
||||||
|
```
|
||||||
|
ZNAVSRFG-<UUID>-<SHA256>
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `ZNAVSRFG` is the magic bytes string (literal ASCII)
|
||||||
|
- `<UUID>` is the hex-encoded UUID from the outer message
|
||||||
|
- `<SHA256>` is the hex-encoded SHA-256 hash from the outer message (covering compressed data)
|
||||||
|
|
||||||
|
Components are separated by hyphens. The signature is produced by GPG over
|
||||||
|
this canonical string and stored in the `signature` field of the outer
|
||||||
|
message.
|
||||||
|
|
||||||
|
## Deterministic Serialization
|
||||||
|
|
||||||
|
By default, manifests are generated deterministically:
|
||||||
|
|
||||||
|
- File entries are sorted by `path` in **lexicographic byte order**
|
||||||
|
- `createdAt` is omitted unless explicitly requested
|
||||||
|
- `atime` is never included (field removed from schema)
|
||||||
|
|
||||||
|
This ensures that two independent runs over the same directory tree produce
|
||||||
|
byte-identical `.mf` files (assuming file contents and metadata have not
|
||||||
|
changed).
|
||||||
|
|
||||||
|
## MIME Type
|
||||||
|
|
||||||
|
The recommended MIME type for `.mf` files is `application/octet-stream`.
|
||||||
|
The `.mf` file extension is the canonical identifier.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Proto definition: [`mfer/mf.proto`](mfer/mf.proto)
|
||||||
|
- Reference implementation: [git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
||||||
@@ -1,44 +1,88 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks build generate fuzz vulncheck
|
export DOCKER_BUILDKIT := 1
|
||||||
|
export PROGRESS_NO_TRUNC := 1
|
||||||
|
GOPATH := $(shell go env GOPATH)
|
||||||
|
export PATH := $(PATH):$(GOPATH)/bin
|
||||||
|
PROTOC_GEN_GO := $(GOPATH)/bin/protoc-gen-go
|
||||||
|
SOURCEFILES := mfer/*.go mfer/*.proto internal/*/*.go cmd/*/*.go go.mod go.sum
|
||||||
|
ARCH := $(shell uname -m)
|
||||||
|
GITREV_BUILD := $(shell bash $(PWD)/bin/gitrev.sh)
|
||||||
|
APPNAME := mfer
|
||||||
|
VERSION := 0.1.0
|
||||||
|
export DOCKER_IMAGE_CACHE_DIR := $(HOME)/Library/Caches/Docker/$(APPNAME)-$(ARCH)
|
||||||
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
|
GOLDFLAGS += -X main.Gitrev=$(GITREV_BUILD)
|
||||||
|
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
.PHONY: docker default run ci test fixme check check-fmt lint
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
|
||||||
# of README.md).
|
|
||||||
|
|
||||||
bootstrap:
|
default: fmt test
|
||||||
@script/bootstrap
|
|
||||||
|
|
||||||
setup:
|
run: ./bin/mfer
|
||||||
@script/setup
|
./$<
|
||||||
|
./$< gen
|
||||||
|
|
||||||
test:
|
ci: test
|
||||||
@script/test
|
|
||||||
|
|
||||||
lint:
|
test: $(SOURCEFILES) mfer/mf.pb.go
|
||||||
@script/lint
|
go test -v --timeout 10s ./...
|
||||||
|
|
||||||
fmt:
|
$(PROTOC_GEN_GO):
|
||||||
@script/fmt
|
test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
|
||||||
|
|
||||||
fmt-check:
|
fixme:
|
||||||
@script/fmt-check
|
@grep -nir fixme . | grep -v Makefile
|
||||||
|
|
||||||
check:
|
devprereqs:
|
||||||
@script/check
|
which golangci-lint || go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
docker:
|
mfer/mf.pb.go: mfer/mf.proto
|
||||||
@script/docker
|
cd mfer && go generate .
|
||||||
|
|
||||||
hooks:
|
bin/mfer: $(SOURCEFILES) mfer/mf.pb.go
|
||||||
@script/install-precommit
|
protoc --version
|
||||||
|
cd cmd/mfer && go build -tags urfave_cli_no_docs -o ../../bin/mfer $(GOFLAGS) .
|
||||||
|
|
||||||
build:
|
clean:
|
||||||
@script/build
|
rm -rfv mfer/*.pb.go bin/mfer cmd/mfer/mfer *.dockerimage
|
||||||
|
|
||||||
generate:
|
fmt: mfer/mf.pb.go
|
||||||
@script/generate
|
gofumpt -l -w mfer internal cmd
|
||||||
|
golangci-lint run --fix
|
||||||
|
-prettier -w *.json
|
||||||
|
-prettier -w *.md
|
||||||
|
|
||||||
fuzz:
|
lint: mfer/mf.pb.go
|
||||||
@script/fuzz
|
golangci-lint run ./...
|
||||||
|
|
||||||
vulncheck:
|
docker: sneak-mfer.$(ARCH).tzst.dockerimage
|
||||||
@script/vulncheck
|
|
||||||
|
sneak-mfer.$(ARCH).tzst.dockerimage: $(SOURCEFILES) vendor.tzst modcache.tzst
|
||||||
|
docker build --progress plain --build-arg GITREV=$(GITREV_BUILD) -t sneak/mfer .
|
||||||
|
docker save sneak/mfer | pv | zstdmt -19 > $@
|
||||||
|
du -sh $@
|
||||||
|
|
||||||
|
godoc:
|
||||||
|
open http://127.0.0.1:6060
|
||||||
|
godoc -http=:6060
|
||||||
|
|
||||||
|
vendor.tzst: go.mod go.sum
|
||||||
|
go mod tidy
|
||||||
|
go mod vendor
|
||||||
|
cd vendor && tar -c . | pv | zstdmt -19 > $(PWD)/$@.tmp
|
||||||
|
rm -rf vendor
|
||||||
|
mv $@.tmp $@
|
||||||
|
|
||||||
|
modcache.tzst: go.mod go.sum
|
||||||
|
go mod tidy
|
||||||
|
cd $(HOME)/go/pkg && chmod -R u+rw . && rm -rf mod sumdb
|
||||||
|
go mod download -x
|
||||||
|
cd $(shell go env GOMODCACHE) && tar -c . | pv | zstdmt -19 > $(PWD)/$@.tmp
|
||||||
|
mv $@.tmp $@
|
||||||
|
|
||||||
|
# Individual check targets
|
||||||
|
check-fmt:
|
||||||
|
@echo "==> Checking formatting..."
|
||||||
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
|
# Run all checks (formatting, linting, tests) without modifying files
|
||||||
|
check: check-fmt lint test
|
||||||
|
|||||||
@@ -1,229 +1,162 @@
|
|||||||
# mfer
|
# mfer
|
||||||
|
|
||||||
[mfer](https://git.eeqj.de/sneak/mfer) is a [WTFPL](https://wtfpl.net)-licensed
|
[mfer](https://git.eeqj.de/sneak/mfer) is a reference implementation library
|
||||||
(public domain) [Go](https://golang.org) library and command-line tool by
|
and thin wrapper command-line utility written in [Go](https://golang.org)
|
||||||
[@sneak](https://sneak.berlin) that specifies and generates `.mf` manifest files
|
and first published in 2022 under the [WTFPL](https://wtfpl.net) (public
|
||||||
over a directory tree to encapsulate metadata about the files — such as
|
domain) license. It specifies and generates `.mf` manifest files over a
|
||||||
cryptographic checksums and signatures over same — to aid in archiving,
|
directory tree of files to encapsulate metadata about them (such as
|
||||||
downloading, streaming, and mirroring. It was first published in 2022. The
|
cryptographic checksums or signatures over same) to aid in archiving,
|
||||||
manifest files' data is serialized with Google's
|
downloading, and streaming, or mirroring. The manifest files' data is
|
||||||
[protobuf serialization format](https://developers.google.com/protocol-buffers).
|
serialized with Google's [protobuf serialization
|
||||||
The structure of these files can be found
|
format](https://developers.google.com/protocol-buffers). The structure of
|
||||||
[in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
these files can be found in the [format specification](FORMAT.md) and the
|
||||||
which is included in the [project repository](https://git.eeqj.de/sneak/mfer).
|
[protobuf schema](mfer/mf.proto), both included in the [project
|
||||||
|
repository](https://git.eeqj.de/sneak/mfer).
|
||||||
|
|
||||||
The current version is pre-1.0 and while the repo was published in 2022, there
|
The current version is pre-1.0 and while the repo was published in 2022,
|
||||||
has not yet been any versioned release. [SemVer](https://semver.org) will be
|
there has not yet been any versioned release. [SemVer](https://semver.org)
|
||||||
used for releases.
|
will be used for releases.
|
||||||
|
|
||||||
This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
|
This project was started by [@sneak](https://sneak.berlin) to scratch an
|
||||||
2022 and is currently a one-person effort, though the goal is for this to emerge
|
itch in 2022 and is currently a one-person effort, though the goal is for
|
||||||
as a de-facto standard and be incorporated into other software. A compatible
|
this to emerge as a de-facto standard and be incorporated into other
|
||||||
javascript library is planned.
|
software. A compatible javascript library is planned.
|
||||||
|
|
||||||
# Getting Started
|
# Phases
|
||||||
|
|
||||||
`mfer` builds from source with Go 1.27.1 or later. The generated protobuf code
|
Manifest generation happens in two distinct phases:
|
||||||
is committed, so no `protoc` toolchain is required:
|
|
||||||
|
## Phase 1: Enumeration
|
||||||
|
|
||||||
|
Walking directories and calling `stat()` on files to collect metadata (path, size, mtime, ctime). This builds the list of files to be scanned. Relatively fast as it only reads filesystem metadata, not file contents.
|
||||||
|
|
||||||
|
**Progress:** `EnumerateStatus` with `FilesFound` and `BytesFound`
|
||||||
|
|
||||||
|
## Phase 2: Scan (ToManifest)
|
||||||
|
|
||||||
|
Reading file contents and computing cryptographic hashes for manifest generation. This is the expensive phase that reads all file data from disk.
|
||||||
|
|
||||||
|
**Progress:** `ScanStatus` with `TotalFiles`, `ScannedFiles`, `TotalBytes`, `ScannedBytes`, `BytesPerSec`
|
||||||
|
|
||||||
|
# Code Conventions
|
||||||
|
|
||||||
|
- **Logging:** Never use `fmt.Printf` or write to stdout/stderr directly in normal code. Use the `internal/log` package for all output (`log.Info`, `log.Infof`, `log.Debug`, `log.Debugf`, `log.Progressf`, `log.ProgressDone`).
|
||||||
|
- **Filesystem abstraction:** Use `github.com/spf13/afero` for filesystem operations to enable testing and flexibility.
|
||||||
|
- **CLI framework:** Use `github.com/urfave/cli/v2` for command-line interface.
|
||||||
|
- **Serialization:** Use Protocol Buffers for manifest file format.
|
||||||
|
- **Internal packages:** Non-exported implementation details go in `internal/` subdirectories.
|
||||||
|
- **Concurrency:** Use `sync.RWMutex` for protecting shared state; prefer channels for progress reporting.
|
||||||
|
- **Progress channels:** Use buffered channels (size 1) with non-blocking sends to avoid blocking the main operation if the consumer is slow.
|
||||||
|
- **Context support:** Long-running operations should accept `context.Context` for cancellation.
|
||||||
|
- **NO_COLOR:** Respect the `NO_COLOR` environment variable for disabling colored output.
|
||||||
|
- **Options pattern:** Use `NewWithOptions(opts *Options)` constructor pattern for configurable types.
|
||||||
|
|
||||||
|
# Building
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.21 or later
|
||||||
|
- `protoc` (Protocol Buffers compiler) — only needed if modifying `.proto` files
|
||||||
|
- `golangci-lint` — for linting (`go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`)
|
||||||
|
- `gofumpt` — for formatting (`go install mvdan.cc/gofumpt@latest`)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.eeqj.de/sneak/mfer.git
|
# Build the binary
|
||||||
cd mfer
|
make bin/mfer
|
||||||
make build
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
make fmt
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
make lint
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate a manifest for a directory tree, verify it later, and fetch a published
|
## Install from source
|
||||||
tree by URL:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Write index.mf, a manifest of the files under the current directory.
|
go install sneak.berlin/go/mfer/cmd/mfer@latest
|
||||||
bin/mfer gen .
|
|
||||||
|
|
||||||
# Verify the files on disk against the manifest. Exits nonzero if any file
|
|
||||||
# it lists is missing or corrupted; warns about files it does not list.
|
|
||||||
bin/mfer check index.mf
|
|
||||||
|
|
||||||
# Download and cryptographically verify a tree published over HTTP into
|
|
||||||
# ./mirror: mfer fetches <url>/index.mf, downloads every file it lists,
|
|
||||||
# skipping any already there with the right hash, then saves the manifest as
|
|
||||||
# mirror/index.mf.
|
|
||||||
bin/mfer fetch --dest mirror https://example.com/tree/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `bin/mfer help` for the full command list, or `bin/mfer <command> --help`
|
|
||||||
for a single command's options.
|
|
||||||
|
|
||||||
# Build Status
|
# Build Status
|
||||||
|
|
||||||
CI runs `script/cibuild`, which runs `script/bootstrap` and `script/check`, then
|
[](https://drone.datavi.be/sneak/mfer)
|
||||||
builds the Docker image with `--no-cache`, so the lint and test phases in the
|
|
||||||
`Dockerfile` run on every build. The `main` branch must always be green.
|
|
||||||
|
|
||||||
# Entrypoints
|
|
||||||
|
|
||||||
This repository adheres to the
|
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
|
||||||
development workflow, and the Makefile targets are thin shims that call them. We
|
|
||||||
provide:
|
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies, idempotently: Go and the
|
|
||||||
modules of `go.mod`; node (the version `.nvmrc` names, through nvm when there
|
|
||||||
is no node on `PATH`) and yarn, plus the prettier version pinned in
|
|
||||||
`package.json`/`yarn.lock`; `gofumpt` v0.12.0 and `protoc-gen-go` v1.36.12,
|
|
||||||
installed into `bin/` with `go install`, each pinned to a commit; and `protoc`
|
|
||||||
33.4, unpacked into `bin/protoc` from its release archive once the archive
|
|
||||||
matches the sha256 the script holds for this platform. Each of those three is
|
|
||||||
installed again whenever the one in `bin/` reports another version.
|
|
||||||
golangci-lint is not installed, it runs only in Docker
|
|
||||||
- `script/setup` — make a fresh clone ready for development: runs
|
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
|
||||||
- `script/projectname` — output the project name (`mfer`); used by other scripts
|
|
||||||
such as `script/docker`
|
|
||||||
- `script/test` — run the test suite in Docker: builds only the `test` stage of
|
|
||||||
the `Dockerfile`, whose build runs `go test -race`, uncached so it runs every
|
|
||||||
time; one test fails when `mfer/mf.proto` no longer matches the hash
|
|
||||||
`script/generate` recorded
|
|
||||||
- `script/build` (`make build`) — build the `mfer` binary into `bin/mfer`,
|
|
||||||
stamped with the revision `mfer version` prints: the output of
|
|
||||||
`git describe --tags --always --dirty`, as `script/docker` passes it
|
|
||||||
- `script/generate` (`make generate`) — regenerate `mfer/mf.pb.go` from
|
|
||||||
`mfer/mf.proto` and record the hash of that `mfer/mf.proto` in
|
|
||||||
`mfer/mf.proto.sha256`; the only thing that regenerates the committed
|
|
||||||
`mfer/mf.pb.go`. It runs the `protoc` that `script/bootstrap` unpacks into
|
|
||||||
`bin/protoc` and the `protoc-gen-go` it installs into `bin/`, refusing any
|
|
||||||
version of either but the pinned one
|
|
||||||
- `script/fuzz` — fuzz the manifest parser for one minute; run by hand
|
|
||||||
(`make fuzz`), never by CI, while `script/test` runs its committed seed corpus
|
|
||||||
as ordinary tests
|
|
||||||
- `script/lint` — run `golangci-lint` in Docker: builds only the `lint` stage of
|
|
||||||
the `Dockerfile`, whose build runs the linter, uncached so it runs every time
|
|
||||||
- `script/vulncheck` (`make vulncheck`) — run `govulncheck` in Docker: builds
|
|
||||||
only the `vulncheck` stage of the `Dockerfile`, uncached, which reports known
|
|
||||||
vulnerabilities in the code `mfer` calls, from the Go vulnerability database.
|
|
||||||
`script/check` does not run it, so an advisory published later never turns the
|
|
||||||
gate red
|
|
||||||
- `script/fmt` — format all code and docs (writes): `script/gofumpt --write` and
|
|
||||||
`script/prettier --write`
|
|
||||||
- `script/gofumpt` — run `gofumpt` over every Go file in the repository in the
|
|
||||||
given mode, `--write` or `--check`, with the `bin/gofumpt` that
|
|
||||||
`script/bootstrap` installs, refusing any version but the pinned one;
|
|
||||||
`script/fmt` and `script/fmt-check` both go through it, so they cannot
|
|
||||||
disagree about Go formatting
|
|
||||||
- `script/prettier` — run prettier over the repository's canonical file set
|
|
||||||
(Markdown and JSON, minus `.prettierignore`) in the given mode, `--write` or
|
|
||||||
`--check`, with the prettier version `yarn.lock` pins, run by the node on
|
|
||||||
`PATH` or else the one `script/bootstrap` installed through nvm; the single
|
|
||||||
definition of that file set, so `script/fmt` and `script/fmt-check` cannot
|
|
||||||
disagree about it
|
|
||||||
- `script/fmt-check` — check formatting without writing:
|
|
||||||
`script/gofumpt --check` plus `script/prettier --check`
|
|
||||||
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`
|
|
||||||
- `script/docker` — build the Docker image tagged with the project name
|
|
||||||
- `script/cibuild` — CI entrypoint: runs `script/bootstrap` and `script/check`,
|
|
||||||
then builds the image with the same command as `script/docker`, uncached, so
|
|
||||||
the lint and test phases in the `Dockerfile` run every time
|
|
||||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then
|
|
||||||
`script/check`
|
|
||||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
|
||||||
`script/precommit`
|
|
||||||
|
|
||||||
# Participation
|
# Participation
|
||||||
|
|
||||||
The community is as yet nonexistent so there are no defined policies or norms
|
The community is as yet nonexistent so there are no defined policies or
|
||||||
yet. Primary development happens on a privately-run Gitea instance at
|
norms yet. Primary development happens on a privately-run Gitea instance at
|
||||||
[https://git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer) and issues are
|
[https://git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer) and issues
|
||||||
[tracked there](https://git.eeqj.de/sneak/mfer/issues).
|
are [tracked there](https://git.eeqj.de/sneak/mfer/issues).
|
||||||
|
|
||||||
Changes must always be formatted with a standard `go fmt`, syntactically valid,
|
Changes must always be formatted with a standard `go fmt`, syntactically
|
||||||
and must pass the linting defined in the repository's `.golangci.yml`, which
|
valid, and must pass the linting defined in the repository (presently only
|
||||||
`make lint` runs in Docker. The `main` branch is protected and all changes must
|
the `golangci-lint` defaults), which can be run with a `make lint`. The
|
||||||
be made via [pull requests](https://git.eeqj.de/sneak/mfer/pulls) and pass CI to
|
`main` branch is protected and all changes must be made via [pull
|
||||||
be merged. Any changes submitted to this project must also be
|
requests](https://git.eeqj.de/sneak/mfer/pulls) and pass CI to be merged.
|
||||||
|
Any changes submitted to this project must also be
|
||||||
[WTFPL-licensed](https://wtfpl.net) to be considered.
|
[WTFPL-licensed](https://wtfpl.net) to be considered.
|
||||||
|
|
||||||
See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards,
|
# Problem Statement
|
||||||
tooling requirements, and workflow conventions.
|
|
||||||
|
|
||||||
# Rationale
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
Given a plain URL, there is no standard way to safely and programmatically
|
Given a plain URL, there is no standard way to safely and programmatically
|
||||||
download everything "under" that URL path. `wget -r` can traverse directory
|
download everything "under" that URL path. `wget -r` can traverse directory
|
||||||
listings if they're enabled, but every server has a different format, and this
|
listings if they're enabled, but every server has a different format, and
|
||||||
does not verify cryptographic integrity of the files, or enable them to be
|
this does not verify cryptographic integrity of the files, or enable them to
|
||||||
fetched using a different protocol other than HTTP/s.
|
be fetched using a different protocol other than HTTP/s.
|
||||||
|
|
||||||
Currently, the solution that people are using are sidecar files in the format of
|
Currently, the solution that people are using are sidecar files in the
|
||||||
`SHASUMS` checksum files, as well as a `SHASUMS.asc` PGP detached signature.
|
format of `SHASUMS` checksum files, as well as a `SHASUMS.asc` PGP detached
|
||||||
This is not checksum-algorithm-agnostic and the sidecar file is not always
|
signature. This is not checksum-algorithm-agnostic and the sidecar file is
|
||||||
consistently named.
|
not always consistently named.
|
||||||
|
|
||||||
Real issues I face:
|
Real issues I face:
|
||||||
|
|
||||||
- when I plug in an ExFAT hard drive, I don't know if any files on the
|
- when I plug in an ExFAT hard drive, I don't know if any files on the
|
||||||
filesystem are corrupted or missing
|
filesystem are corrupted or missing
|
||||||
- current ad-hoc solution are `SHASUMS`/`SHASUMS.asc` files
|
- current ad-hoc solution are `SHASUMS`/`SHASUMS.asc` files
|
||||||
- when I want to mirror an HTTP archive, I have to use special tools like
|
- when I want to mirror an HTTP archive, I have to use special tools like
|
||||||
debmirror that understand the archive format
|
debmirror that understand the archive format
|
||||||
- the debian repository metadata structure is hot garbage
|
- the debian repository metadata structure is hot garbage
|
||||||
- when I download a large file via HTTP, I have no way of knowing if the file
|
- when I download a large file via HTTP, I have no way of knowing if the
|
||||||
content is what it's supposed to be
|
file content is what it's supposed to be
|
||||||
|
|
||||||
## The solution
|
# Proposed Solution
|
||||||
|
|
||||||
A standard, a manifest file format, and a tool for generating same.
|
A standard, a manifest file format, and a tool for generating same.
|
||||||
|
|
||||||
The manifest file would be called `index.mf`, and the tool for generating such
|
The manifest file would be called `index.mf`, and the tool for generating such would be called `mfer`.
|
||||||
would be called `mfer`.
|
|
||||||
|
|
||||||
The manifest file would do several important things:
|
The manifest file would do several important things:
|
||||||
|
|
||||||
- have a standard filename, so if given `https://example.com/downloadpackage/`
|
- have a standard filename, so if given
|
||||||
one could fetch `https://example.com/downloadpackage/index.mf` to enumerate
|
`https://example.com/downloadpackage/` one could fetch
|
||||||
the full directory listing.
|
`https://example.com/downloadpackage/index.mf` to enumerate the full
|
||||||
|
directory listing.
|
||||||
- contain a version field for extensibility
|
- contain a version field for extensibility
|
||||||
- contain structured data (protobuf, json, or cbor)
|
- contain structured data (protobuf, json, or cbor)
|
||||||
- provide an inner signed container, so that the manifest file itself can embed
|
- provide an inner signed container, so that the manifest file itself can
|
||||||
a signature and a public key alongside in a single file
|
embed a signature and a public key alongside in a single file
|
||||||
- contain a list of files, each with a relative path to the manifest
|
- contain a list of files, each with a relative path to the manifest
|
||||||
- contain manifest timestamp
|
- contain manifest timestamp
|
||||||
- contain ctime/mtime information for files so that file metadata can be
|
- contain ctime/mtime information for files so that file metadata can be
|
||||||
preserved
|
preserved
|
||||||
- contain cryptographic checksums in several different algorithms for each file
|
- contain cryptographic checksums in several different algorithms for each
|
||||||
- probably encoded with multihash to indicate algo + hash
|
file
|
||||||
- sha256 at the minimum
|
- probably encoded with multihash to indicate algo + hash
|
||||||
- would be nice to include an IPFS/IPLD CIDv1 root hash for each file, which
|
- sha256 at the minimum
|
||||||
likely involves doing an ipfs file object chunking
|
- would be nice to include an IPFS/IPLD CIDv1 root hash for each file,
|
||||||
- maybe even including the complete IPFS/IPLD directory tree objects and
|
which likely involves doing an ipfs file object chunking
|
||||||
chunklists?
|
- maybe even including the complete IPFS/IPLD directory tree objects and
|
||||||
- this is because generating an `index.mf` does not imply publishing on
|
chunklists?
|
||||||
ipfs at that time
|
- this is because generating an `index.mf` does not imply publishing on
|
||||||
- maybe a bittorrent chunklist for torrent client compatibility? perhaps a
|
ipfs at that time
|
||||||
top-level infohash for the whole manifest?
|
- maybe a bittorrent chunklist for torrent client compatibility? perhaps a
|
||||||
|
top-level infohash for the whole manifest?
|
||||||
# Design
|
|
||||||
|
|
||||||
The repository is split into a reusable library and a thin command-line wrapper
|
|
||||||
around it.
|
|
||||||
|
|
||||||
- `mfer/` is the reusable library and the heart of the project: it defines the
|
|
||||||
manifest format and implements building, scanning, checking, serialization,
|
|
||||||
and signing. The protobuf schema is `mfer/mf.proto`, and the generated code it
|
|
||||||
produces (`mfer/mf.pb.go`) is committed alongside it so the library builds
|
|
||||||
with `go get` and needs no `protoc` toolchain.
|
|
||||||
- `internal/cli/` holds the command implementations — `generate` (alias `gen`),
|
|
||||||
`check`, `freshen`, `export`, `list` (alias `ls`), `fetch`, and `version` —
|
|
||||||
that wire the library to the command-line interface.
|
|
||||||
- `internal/log/` provides the logging used by the commands and the library.
|
|
||||||
- `internal/bork/` provides the error the library returns when a manifest's
|
|
||||||
decompressed contents are not the size the manifest records.
|
|
||||||
- `cmd/mfer/` is the entrypoint: its `main` package runs `internal/cli` and
|
|
||||||
exits with the status it returns.
|
|
||||||
|
|
||||||
Everything under `internal/` is private to this repository; only the `mfer/`
|
|
||||||
package is intended for import by other software.
|
|
||||||
|
|
||||||
# Design Goals
|
# Design Goals
|
||||||
|
|
||||||
@@ -237,64 +170,41 @@ package is intended for import by other software.
|
|||||||
# Non-Goals
|
# Non-Goals
|
||||||
|
|
||||||
- Manifest generation speed
|
- Manifest generation speed
|
||||||
- likely involves IPFS chunking, bittorrent chunking, and several different
|
- likely involves IPFS chunking, bittorrent chunking, and several
|
||||||
cryptographic hash functions over the entirety of each and every file
|
different cryptographic hash functions over the entirety of each and
|
||||||
|
every file
|
||||||
- Small manifest file size (within reason)
|
- Small manifest file size (within reason)
|
||||||
- 30MiB files are "small" these days, given modern storage/bandwidth
|
- 30MiB files are "small" these days, given modern storage/bandwidth
|
||||||
- metadata size should not be used as an excuse to sacrifice utility (such
|
- metadata size should not be used as an excuse to sacrifice utility (such
|
||||||
as providing checksums over each chunk of a large file)
|
as providing checksums over each chunk of a large file)
|
||||||
|
|
||||||
# Original Design Questions
|
# Limitations
|
||||||
|
|
||||||
These were the open questions when the project started; open design questions
|
- **Manifest size:** Manifests must fit entirely in system memory during reading and writing.
|
||||||
are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
|
||||||
|
|
||||||
- Should the manifest file include checksums of individual file chunks, or just
|
# Open Questions
|
||||||
for the whole assembled file? If so, should the chunk size be fixed or
|
|
||||||
dynamic? Still open, on
|
- Should the manifest file include checksums of individual file chunks, or just for the whole assembled file?
|
||||||
[issue 81](https://git.eeqj.de/sneak/mfer/issues/81#issuecomment-118698).
|
|
||||||
|
- If so, should the chunksize be fixed or dynamic?
|
||||||
|
|
||||||
- Should the manifest signature format be GnuPG signatures, or those from
|
- Should the manifest signature format be GnuPG signatures, or those from
|
||||||
OpenBSD's signify (of which there is a good
|
OpenBSD's signify (of which there is a good [golang
|
||||||
[golang implementation](https://github.com/frankbraun/gosignify))? Still open,
|
implementation](https://github.com/frankbraun/gosignify)?
|
||||||
as question 10 on [issue 82](https://git.eeqj.de/sneak/mfer/issues/82).
|
|
||||||
|
|
||||||
- Should the on-disk serialization format be proto3 or json? Settled: it is
|
- Should the on-disk serialization format be proto3 or json?
|
||||||
proto3, see `docs/FORMAT.md` and `mfer/mf.proto`.
|
|
||||||
|
|
||||||
# Tool Examples
|
# Tool Examples
|
||||||
|
|
||||||
- `mfer gen` / `mfer gen .`
|
- `mfer gen` / `mfer gen .`
|
||||||
- recurses under current directory and writes out an `index.mf`
|
- recurses under current directory and writes out an `index.mf`
|
||||||
- records every file's mode as `0000` unless given `--include-permissions`,
|
|
||||||
which records each file's permission bits (`0777` at most)
|
|
||||||
- `mfer check` / `mfer check .`
|
- `mfer check` / `mfer check .`
|
||||||
- verifies checksums of all files in manifest, displaying error and exiting
|
- verifies checksums of all files in manifest, displaying error and
|
||||||
nonzero if any files are missing or corrupted, or have permission bits
|
exiting nonzero if any files are missing or corrupted
|
||||||
other than the mode the manifest records, unless that is `0000`
|
|
||||||
- warns about each file under the base directory that the manifest does not
|
|
||||||
list, hidden files included; with `--no-extra-files` each one is a failure
|
|
||||||
instead
|
|
||||||
- `mfer fetch https://example.com/stuff/`
|
- `mfer fetch https://example.com/stuff/`
|
||||||
- fetches `/stuff/index.mf` and downloads all files listed in manifest into
|
- fetches `/stuff/index.mf` and downloads all files listed in manifest,
|
||||||
the current directory, or the one given with `--dest`, and assures
|
optionally resuming any that already exist locally, and assures
|
||||||
cryptographic integrity of downloaded files. A file already there with the
|
cryptographic integrity of downloaded files.
|
||||||
size, hash and recorded mode the manifest lists is skipped. Once every
|
|
||||||
file is in place, the manifest is saved there as `index.mf`, so
|
|
||||||
`mfer check` can verify the tree later. Each file is downloaded to a temp
|
|
||||||
file beside it, such as `.a.txt.tmp` for `a.txt`, given the mode the
|
|
||||||
manifest records unless that is `0000`, then moved into place. A manifest
|
|
||||||
is refused before any file is downloaded if it records a mode above
|
|
||||||
`0777`, or lists a file where fetch writes another: at another listed file
|
|
||||||
or a directory one is in, at the temp file of a listed file, or at
|
|
||||||
`index.mf` or `.index.mf.tmp` at the top of the tree. Names are compared
|
|
||||||
in any letter case, on every filesystem, since on a case-insensitive one
|
|
||||||
`A.txt` and `a.txt` are one file; a directory two listed files are in must
|
|
||||||
be spelled alike in both.
|
|
||||||
- `mfer fetch --require-signature <fingerprint> https://example.com/stuff/`
|
|
||||||
- as above, but first refuses a manifest not signed by the key with that
|
|
||||||
fingerprint, as `mfer check --require-signature` does, before downloading
|
|
||||||
any file.
|
|
||||||
|
|
||||||
# Implementation Plan
|
# Implementation Plan
|
||||||
|
|
||||||
@@ -311,31 +221,30 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
|||||||
# Hopes And Dreams
|
# Hopes And Dreams
|
||||||
|
|
||||||
- `aria2c https://example.com/manifestdirectory/`
|
- `aria2c https://example.com/manifestdirectory/`
|
||||||
- (fetches `https://example.com/manifestdirectory/index.mf`, downloads and
|
- (fetches `https://example.com/manifestdirectory/index.mf`, downloads and
|
||||||
checksums all files, resumes any that exist locally already)
|
checksums all files, resumes any that exist locally already)
|
||||||
- `mfer fetch https://example.com/manifestdirectory/`
|
- `mfer fetch https://example.com/manifestdirectory/`
|
||||||
- a command line option to zero/omit mtime/ctime, as well as manifest timestamp,
|
- a command line option to zero/omit mtime/ctime, as well as manifest
|
||||||
and sort all directory listings so that manifest file generation is
|
timestamp, and sort all directory listings so that manifest file
|
||||||
deterministic/reproducible
|
generation is deterministic/reproducible
|
||||||
- URL format
|
- URL format `mfer fetch https://exmaple.com/manifestdirectory/?key=5539AD00DE4C42F3AFE11575052443F4DF2A55C2`
|
||||||
`mfer fetch https://exmaple.com/manifestdirectory/?key=5539AD00DE4C42F3AFE11575052443F4DF2A55C2`
|
to assert in the URL which PGP signing key should be used in the manifest,
|
||||||
to assert in the URL which PGP signing key should be used in the manifest, so
|
so that shared URLs have a cryptographic trust root
|
||||||
that shared URLs have a cryptographic trust root
|
- a "well-known" key in the manifest that maps well known keys (could reuse
|
||||||
- a "well-known" key in the manifest that maps well known keys (could reuse the
|
the http spec) to specific file paths in the manifest.
|
||||||
http spec) to specific file paths in the manifest.
|
- example: a `berlin.sneak.app.slideshow` key that maps to a json
|
||||||
- example: a `berlin.sneak.app.slideshow` key that maps to a json slideshow
|
slideshow config listing what image paths to show, and for how long, and
|
||||||
config listing what image paths to show, and for how long, and in what
|
in what order
|
||||||
order
|
|
||||||
|
|
||||||
# Use Cases
|
# Use Cases
|
||||||
|
|
||||||
## Web Images
|
## Web Images
|
||||||
|
|
||||||
I'd like to be able to put a bunch of images into a directory, generate a
|
I'd like to be able to put a bunch of images into a directory, generate a
|
||||||
manifest, and then point a slideshow client (such as an ambient display, or a
|
manifest, and then point a slideshow client (such as an ambient display, or
|
||||||
react app with the target directory in a query string arg) at that statically
|
a react app with the target directory in a query string arg) at that
|
||||||
hosted directory, and have it discover the full list of images available at that
|
statically hosted directory, and have it discover the full list of images
|
||||||
URL.
|
available at that URL.
|
||||||
|
|
||||||
## Software Distribution
|
## Software Distribution
|
||||||
|
|
||||||
@@ -345,44 +254,29 @@ resumably by either HTTP or IPFS/BitTorrent without a .torrent file.
|
|||||||
## Filesystem Archive Integrity
|
## Filesystem Archive Integrity
|
||||||
|
|
||||||
I use filesystems that don't include data checksums, and I would like a
|
I use filesystems that don't include data checksums, and I would like a
|
||||||
cryptographically signed checksum file so that I can later verify that a set of
|
cryptographically signed checksum file so that I can later verify that a set
|
||||||
archive files have not been modified, none are missing, and that the checksums
|
of archive files have not been modified, none are missing, and that the
|
||||||
have not been altered in storage by a second party.
|
checksums have not been altered in storage by a second party.
|
||||||
|
|
||||||
## Filesystem-Independent Checksums
|
## Filesystem-Independent Checksums
|
||||||
|
|
||||||
I would like to be able to plug in a hard drive or flash drive and, if there is
|
I would like to be able to plug in a hard drive or flash drive and, if there
|
||||||
an `index.mf` in the root, automatically detect missing/corrupted files,
|
is an `index.mf` in the root, automatically detect missing/corrupted files,
|
||||||
regardless of filesystem format.
|
regardless of filesystem format.
|
||||||
|
|
||||||
# Collaboration
|
# Collaboration
|
||||||
|
|
||||||
Please email [`sneak@sneak.berlin`](mailto:sneak@sneak.berlin) with your desired
|
Please email [`sneak@sneak.berlin`](mailto:sneak@sneak.berlin) with your
|
||||||
username for an account on this Gitea instance.
|
desired username for an account on this Gitea instance.
|
||||||
|
|
||||||
# TODO
|
|
||||||
|
|
||||||
Open work, open design questions included, is tracked in this repo's issues:
|
|
||||||
[https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues).
|
|
||||||
|
|
||||||
# See Also
|
|
||||||
|
|
||||||
## Prior Art: Metalink
|
|
||||||
|
|
||||||
- [Metalink - Mozilla Wiki](https://wiki.mozilla.org/Metalink)
|
|
||||||
- [Metalink - Wikipedia](https://en.wikipedia.org/wiki/Metalink)
|
|
||||||
- [RFC 5854 - The Metalink Download Description Format](https://datatracker.ietf.org/doc/html/rfc5854)
|
|
||||||
- [RFC 6249 - Metalink/HTTP: Mirrors and Hashes](https://www.rfc-editor.org/rfc/rfc6249.html)
|
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Repo: [https://git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
- Repo: [https://git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
||||||
- Issues:
|
- Issues: [https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues)
|
||||||
[https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues)
|
|
||||||
|
|
||||||
# Author
|
# Authors
|
||||||
|
|
||||||
- [@sneak](https://sneak.berlin)
|
- [@sneak <sneak@sneak.berlin>](mailto:sneak@sneak.berlin)
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
|
|||||||
@@ -1,679 +0,0 @@
|
|||||||
---
|
|
||||||
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,122 @@
|
|||||||
|
# TODO: mfer 1.0
|
||||||
|
|
||||||
|
## Design Questions
|
||||||
|
|
||||||
|
*sneak: please answer inline below each question. These are preserved for posterity.*
|
||||||
|
|
||||||
|
### Format Design
|
||||||
|
|
||||||
|
**1. Should `MFFileChecksum` be simplified?**
|
||||||
|
Currently it's a separate message wrapping a single `bytes multiHash` field. Since multihash already self-describes the algorithm, `repeated bytes hashes` directly on `MFFilePath` would be simpler and reduce per-file protobuf overhead. Is the extra message layer intentional (e.g. planning to add per-hash metadata like `verified_at`)?
|
||||||
|
|
||||||
|
> *answer:* Leave as-is for now.
|
||||||
|
|
||||||
|
**2. Should file permissions/mode be stored?**
|
||||||
|
The format stores mtime/ctime but not Unix file permissions. For archival use (ExFAT, filesystem-independent checksums) this may not matter, but for software distribution or filesystem restoration it's a gap. Should we reserve a field now (e.g. `optional uint32 mode = 305`) even if we don't populate it yet?
|
||||||
|
|
||||||
|
> *answer:* No, not right now.
|
||||||
|
|
||||||
|
**3. Should `atime` be removed from the schema?**
|
||||||
|
Access time is volatile, non-deterministic, and often disabled (`noatime`). Including it means two manifests of the same directory at different times will differ, which conflicts with the determinism goal. Remove it, or document it as "never set by default"?
|
||||||
|
|
||||||
|
> *answer:* REMOVED — done. Field 304 has been removed from the proto schema.
|
||||||
|
|
||||||
|
**4. What are the path normalization rules?**
|
||||||
|
The proto has `string path` with no specification about: always forward-slash? Must be relative? No `..` components allowed? UTF-8 NFC vs NFD normalization (macOS vs Linux)? Max path length? This is a security issue (path traversal) and a cross-platform compatibility issue. What rules should the spec mandate?
|
||||||
|
|
||||||
|
> *answer:* Implemented — UTF-8, forward-slash only, relative paths only, no `..` segments. Documented in FORMAT.md.
|
||||||
|
|
||||||
|
**5. Should we add a version byte after the magic?**
|
||||||
|
Currently `ZNAVSRFG` is followed immediately by protobuf. Adding a version byte (`ZNAVSRFG\x01`) would allow future framing changes without requiring protobuf parsing to detect the version. `MFFileOuter.Version` serves this purpose but requires successful deserialization to read. Worth the extra byte?
|
||||||
|
|
||||||
|
> *answer:* No — protobuf handles versioning via the `MFFileOuter.Version` field.
|
||||||
|
|
||||||
|
**6. Should we add a length-prefix after the magic?**
|
||||||
|
Protobuf is not self-delimiting. If we ever want to concatenate manifests or append data after the protobuf, the current framing is insufficient. Add a varint or fixed-width length-prefix?
|
||||||
|
|
||||||
|
> *answer:* Not needed now.
|
||||||
|
|
||||||
|
### Signature Design
|
||||||
|
|
||||||
|
**7. What does the outer SHA-256 hash cover — compressed or uncompressed data?**
|
||||||
|
The review notes it currently hashes compressed data (good for verifying before decompression), but this should be explicitly documented. Which is the intended behavior?
|
||||||
|
|
||||||
|
> *answer:* Hash covers compressed data. Documented in FORMAT.md.
|
||||||
|
|
||||||
|
**8. Should `signatureString()` sign raw bytes instead of a hex-encoded string?**
|
||||||
|
Currently the canonical string is `MAGIC-UUID-MULTIHASH` with hex encoding, which adds a transformation layer. Signing the raw `sha256` bytes (or compressed `innerMessage` directly) would be simpler. Keep the string format or switch to raw bytes?
|
||||||
|
|
||||||
|
> *answer:* Keep string format as-is (established).
|
||||||
|
|
||||||
|
**9. Should we support detached signature files (`.mf.sig`)?**
|
||||||
|
Embedded signatures are better for single-file distribution. Detached `.mf.sig` files follow the familiar `SHASUMS`/`SHASUMS.asc` pattern and are simpler for HTTP serving. Support both modes?
|
||||||
|
|
||||||
|
> *answer:* Not for 1.0.
|
||||||
|
|
||||||
|
**10. GPG vs pure-Go crypto for signatures?**
|
||||||
|
Shelling out to `gpg` is fragile (may not be installed, version-dependent output). `github.com/ProtonMail/go-crypto` provides pure-Go OpenPGP, or we could go Ed25519/signify (simpler, no key management). Which direction?
|
||||||
|
|
||||||
|
> *answer:* Keep GPG shelling for now (established).
|
||||||
|
|
||||||
|
### Implementation Design
|
||||||
|
|
||||||
|
**11. Should manifests be deterministic by default?**
|
||||||
|
This means: sort file entries by path, omit `createdAt` timestamp (or make it opt-in), no `atime`. Should determinism be the default, with a `--include-timestamps` flag to opt in?
|
||||||
|
|
||||||
|
> *answer:* YES — implemented, default behavior.
|
||||||
|
|
||||||
|
**12. Should we consolidate or keep both scanner/checker implementations?**
|
||||||
|
There are two parallel implementations: `mfer/scanner.go` + `mfer/checker.go` (typed with `FileSize`, `RelFilePath`) and `internal/scanner/` + `internal/checker/` (raw `int64`, `string`). The `mfer/` versions are superior. Delete the `internal/` versions?
|
||||||
|
|
||||||
|
> *answer:* Consolidated — done (PR#27).
|
||||||
|
|
||||||
|
**13. Should the `manifest` type be exported?**
|
||||||
|
Currently unexported with exported constructors (`New`, `NewFromPaths`, etc.). Consumers can't declare `var m *mfer.manifest`. Export the type, or define an interface?
|
||||||
|
|
||||||
|
> *answer:* Keep unexported.
|
||||||
|
|
||||||
|
**14. What should the Go module path be for 1.0?**
|
||||||
|
Currently mixed between `sneak.berlin/go/mfer` and `git.eeqj.de/sneak/mfer`. Which is canonical?
|
||||||
|
|
||||||
|
> *answer:* `sneak.berlin/go/mfer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Foundation (format correctness)
|
||||||
|
|
||||||
|
- [x] Delete `internal/scanner/` and `internal/checker/` — consolidate on `mfer/` package versions; update CLI code
|
||||||
|
- [x] Add deterministic file ordering — sort entries by path (lexicographic, byte-order) in `Builder.Build()`; add test asserting byte-identical output from two runs
|
||||||
|
- [x] Add decompression size limit — `io.LimitReader` in `deserializeInner()` with `m.pbOuter.Size` as bound
|
||||||
|
- [ ] Fix `errors.Is` dead code in checker — replace with `os.IsNotExist(err)` or `errors.Is(err, fs.ErrNotExist)`
|
||||||
|
- [ ] Fix `AddFile` to verify size — check `totalRead == size` after reading, return error on mismatch
|
||||||
|
- [x] Specify path invariants — add proto comments (UTF-8, forward-slash, relative, no `..`, no leading `/`); validate in `Builder.AddFile` and `Builder.AddFileWithHash`
|
||||||
|
|
||||||
|
### Phase 2: CLI polish
|
||||||
|
|
||||||
|
- [ ] Fix flag naming — all CLI flags use kebab-case as primary (`--include-dotfiles`, `--follow-symlinks`)
|
||||||
|
- [ ] Fix URL construction in fetch — use `BaseURL.JoinPath()` or `url.JoinPath()` instead of string concatenation
|
||||||
|
- [ ] Add progress rate-limiting to Checker — throttle to once per second, matching Scanner
|
||||||
|
- [x] Add `--deterministic` flag (or make it default) — omit `createdAt`, sort files
|
||||||
|
|
||||||
|
### Phase 3: Robustness
|
||||||
|
|
||||||
|
- [ ] Replace GPG subprocess with pure-Go crypto — `github.com/ProtonMail/go-crypto` or Ed25519/signify
|
||||||
|
- [ ] Add timeout to any remaining subprocess calls
|
||||||
|
- [ ] Add fuzzing tests for `NewManifestFromReader`
|
||||||
|
- [ ] Add retry logic to fetch — exponential backoff for transient HTTP errors
|
||||||
|
|
||||||
|
### Phase 4: Format finalization
|
||||||
|
|
||||||
|
- [x] Remove or deprecate `atime` from proto (pending design question answer)
|
||||||
|
- [ ] Reserve `optional uint32 mode = 305` in `MFFilePath` for future file permissions
|
||||||
|
- [ ] Add version byte after magic — `ZNAVSRFG\x01` for format version 1
|
||||||
|
- [x] Write format specification document — separate from README: magic, outer structure, compression, inner structure, path invariants, signature scheme, canonical serialization
|
||||||
|
|
||||||
|
### Phase 5: Release prep
|
||||||
|
|
||||||
|
- [ ] Finalize Go module path
|
||||||
|
- [ ] Audit all error messages for consistency and helpfulness
|
||||||
|
- [ ] Add `--version` output matching SemVer
|
||||||
|
- [ ] Tag v1.0.0
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
if [[ ! -z "$DRONE_COMMIT_SHA" ]]; then
|
||||||
|
echo "${DRONE_COMMIT_SHA:0:7}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -z "$GITREV" ]]; then
|
||||||
|
echo $GITREV
|
||||||
|
else
|
||||||
|
git describe --always --dirty=-dirty
|
||||||
|
fi
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# usage.sh - Generate and check a manifest from the repo
|
|
||||||
# Run from repo root: bin/usage.sh
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$TMPDIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
TMPDIR=$(mktemp -d)
|
|
||||||
MANIFEST="$TMPDIR/index.mf"
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
echo "Building mfer..."
|
|
||||||
go build -o "$TMPDIR/mfer" ./cmd/mfer
|
|
||||||
|
|
||||||
"$TMPDIR/mfer" generate -o "$MANIFEST" .
|
|
||||||
"$TMPDIR/mfer" check --base . "$MANIFEST"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
+1
-7
@@ -1,4 +1,3 @@
|
|||||||
// Command mfer generates and verifies file manifests.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,13 +6,8 @@ import (
|
|||||||
"sneak.berlin/go/mfer/internal/cli"
|
"sneak.berlin/go/mfer/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Appname is the name of this program.
|
|
||||||
const Appname = "mfer"
|
|
||||||
|
|
||||||
// Version and Gitrev are injected at build time via -ldflags.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // set via ldflags at build time
|
|
||||||
var (
|
var (
|
||||||
|
Appname string = "mfer"
|
||||||
Version string
|
Version string
|
||||||
Gitrev string
|
Gitrev string
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-16
@@ -6,20 +6,6 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAppname pins the program name that main passes to cli.Run; it is
|
func TestBuild(t *testing.T) {
|
||||||
// the name that appears in usage output and in the log prefix. It also
|
assert.True(t, true)
|
||||||
// keeps this package compiled under `go test`.
|
|
||||||
func TestAppname(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(t, "mfer", Appname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionDefaults documents that Version and Gitrev are empty unless
|
|
||||||
// injected at build time via -ldflags.
|
|
||||||
func TestVersionDefaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Empty(t, Version)
|
|
||||||
assert.Empty(t, Gitrev)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# usage.sh - Generate and check a manifest from the repo
|
||||||
|
# Run from repo root: ./contrib/usage.sh
|
||||||
|
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
MANIFEST="$TMPDIR/index.mf"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Building mfer..."
|
||||||
|
go build -o "$TMPDIR/mfer" ./cmd/mfer
|
||||||
|
|
||||||
|
"$TMPDIR/mfer" generate -o "$MANIFEST" .
|
||||||
|
"$TMPDIR/mfer" check --base . "$MANIFEST"
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
# .mf File Format Specification
|
|
||||||
|
|
||||||
Version 1.0
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
An `.mf` file is a binary manifest that describes a directory tree of files,
|
|
||||||
including their paths, sizes, and cryptographic checksums. It supports optional
|
|
||||||
GPG signatures for integrity verification and optional timestamps and file
|
|
||||||
permissions for metadata preservation.
|
|
||||||
|
|
||||||
Nothing goes in the 1.0 manifest that 1.0 does not read or write: no field is
|
|
||||||
reserved or kept for later use.
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
An `.mf` file consists of two parts, concatenated:
|
|
||||||
|
|
||||||
1. **Magic bytes** (8 bytes): the ASCII string `ZNAVSRFG`
|
|
||||||
2. **Outer message**: a Protocol Buffers serialized `MFFileOuter` message
|
|
||||||
|
|
||||||
There is no length prefix or version byte between the magic and the protobuf
|
|
||||||
message. The protobuf message extends to the end of the file.
|
|
||||||
|
|
||||||
See [`mfer/mf.proto`](../mfer/mf.proto) for exact field numbers and types.
|
|
||||||
|
|
||||||
## Outer Message (`MFFileOuter`)
|
|
||||||
|
|
||||||
The outer message contains:
|
|
||||||
|
|
||||||
| Field | Number | Type | Description |
|
|
||||||
| ----------------- | ------ | ---------------- | ------------------------------------------------------------------------ |
|
|
||||||
| `version` | 101 | enum | Must be `VERSION_ONE` (1) |
|
|
||||||
| `compressionType` | 102 | enum | Compression of `innerMessage`; must be `COMPRESSION_ZSTD` (1) |
|
|
||||||
| `size` | 103 | int64 | Uncompressed size of `innerMessage` (corruption detection) |
|
|
||||||
| `sha256` | 104 | bytes | SHA-256 hash of the **compressed** `innerMessage` (corruption detection) |
|
|
||||||
| `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID |
|
|
||||||
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
|
|
||||||
| `signature` | 201 | bytes (optional) | GPG signature (ASCII-armored or binary) |
|
|
||||||
| `signer` | 202 | bytes (optional) | Full GPG key ID of the signer |
|
|
||||||
| `signingPubKey` | 203 | bytes (optional) | Full GPG signing public key |
|
|
||||||
|
|
||||||
### SHA-256 Hash
|
|
||||||
|
|
||||||
The `sha256` field (104) covers the **compressed** `innerMessage` bytes. This
|
|
||||||
allows verifying data integrity before decompression.
|
|
||||||
|
|
||||||
## Compression
|
|
||||||
|
|
||||||
The `innerMessage` field is compressed with
|
|
||||||
[Zstandard (zstd)](https://facebook.github.io/zstd/). Implementations must
|
|
||||||
enforce a decompression size limit to prevent decompression bombs. The reference
|
|
||||||
implementation limits decompressed size to 256 MB. It writes zstd frames with a
|
|
||||||
window of at most 8 MiB, the largest window the zstd format recommends decoders
|
|
||||||
support, and refuses frames that ask for a larger one. It also refuses an inner
|
|
||||||
message whose file entries, hashes, timestamps and MIME types, counted at 176,
|
|
||||||
112, 64 and 16 bytes each, add up to more than 8 times its size.
|
|
||||||
|
|
||||||
## Inner Message (`MFFile`)
|
|
||||||
|
|
||||||
After decompressing `innerMessage`, the result is a serialized `MFFile`
|
|
||||||
(referred to as the manifest):
|
|
||||||
|
|
||||||
| Field | Number | Type | Description |
|
|
||||||
| ----------- | ------ | --------------------- | ------------------------------------- |
|
|
||||||
| `version` | 100 | enum | Must be `VERSION_ONE` (1) |
|
|
||||||
| `files` | 101 | repeated `MFFilePath` | List of files in the manifest |
|
|
||||||
| `uuid` | 102 | bytes | Random v4 UUID; must match outer UUID |
|
|
||||||
| `createdAt` | 201 | Timestamp (optional) | When the manifest was created |
|
|
||||||
|
|
||||||
## File Entries (`MFFilePath`)
|
|
||||||
|
|
||||||
Each file entry contains:
|
|
||||||
|
|
||||||
| Field | Number | Type | Description |
|
|
||||||
| ---------- | ------ | ------------------------- | ----------------------------------- |
|
|
||||||
| `path` | 1 | string | Relative file path (see Path Rules) |
|
|
||||||
| `size` | 2 | int64 | File size in bytes |
|
|
||||||
| `hashes` | 3 | repeated `MFFileChecksum` | At least one hash required |
|
|
||||||
| `mimeType` | 301 | string (optional) | MIME type |
|
|
||||||
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
|
||||||
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
|
||||||
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
|
||||||
|
|
||||||
## File Mode
|
|
||||||
|
|
||||||
`mode` holds a file's Unix permission bits, the nine `rwx` bits, so it is never
|
|
||||||
above `0777` (octal); the setuid, setgid and sticky bits are never recorded.
|
|
||||||
Writers record `0000` unless whoever creates the manifest asks for permissions.
|
|
||||||
`0000`, the proto3 default, means no mode was recorded: readers never check or
|
|
||||||
apply it.
|
|
||||||
|
|
||||||
The reference implementation records modes when `gen` or `freshen` is given
|
|
||||||
`--include-permissions`. `check` fails a file whose permission bits differ from
|
|
||||||
a recorded mode other than `0000`. `fetch` sets a recorded mode other than
|
|
||||||
`0000` on each file it writes, and refuses a manifest that records a mode above
|
|
||||||
`0777` before it requests any file.
|
|
||||||
|
|
||||||
## Path Rules
|
|
||||||
|
|
||||||
All `path` values must satisfy these invariants:
|
|
||||||
|
|
||||||
- **UTF-8**: paths must be valid UTF-8
|
|
||||||
- **Forward slashes**: use `/` as the path separator (never `\`)
|
|
||||||
- **Relative only**: no leading `/`
|
|
||||||
- **No parent traversal**: no `..` path segments
|
|
||||||
- **No empty segments**: no `//` sequences
|
|
||||||
- **No trailing slash**: paths refer to files, not directories
|
|
||||||
|
|
||||||
Implementations must validate these invariants when reading and writing
|
|
||||||
manifests. Paths that violate these rules must be rejected.
|
|
||||||
|
|
||||||
## Hash Format (`MFFileChecksum`)
|
|
||||||
|
|
||||||
Each checksum is a single `bytes multiHash` field containing a
|
|
||||||
[multihash](https://multiformats.io/multihash/)-encoded value. Multihash is
|
|
||||||
self-describing: the encoded bytes include a varint algorithm identifier
|
|
||||||
followed by a varint digest length followed by the digest itself.
|
|
||||||
|
|
||||||
The 1.0 implementation writes SHA-256 multihashes (`0x12` algorithm code).
|
|
||||||
Implementations must be able to verify SHA-256 multihashes at minimum.
|
|
||||||
|
|
||||||
## Signature Scheme
|
|
||||||
|
|
||||||
Signing is optional. When present, the signature covers a canonical string
|
|
||||||
constructed as:
|
|
||||||
|
|
||||||
```
|
|
||||||
ZNAVSRFG-<UUID>-<SHA256>
|
|
||||||
```
|
|
||||||
|
|
||||||
Where:
|
|
||||||
|
|
||||||
- `ZNAVSRFG` is the magic bytes string (literal ASCII)
|
|
||||||
- `<UUID>` is the hex-encoded UUID from the outer message
|
|
||||||
- `<SHA256>` is the hex-encoded SHA-256 hash from the outer message (covering
|
|
||||||
compressed data)
|
|
||||||
|
|
||||||
Components are separated by hyphens. The signature is produced by GPG over this
|
|
||||||
canonical string and stored in the `signature` field of the outer message.
|
|
||||||
|
|
||||||
## Deterministic Serialization
|
|
||||||
|
|
||||||
By default, manifests are generated deterministically:
|
|
||||||
|
|
||||||
- File entries are sorted by `path` in **lexicographic byte order**
|
|
||||||
- `createdAt` is omitted unless explicitly requested
|
|
||||||
- `mode` is `0000` unless explicitly requested
|
|
||||||
|
|
||||||
This ensures that two independent runs over the same directory tree produce
|
|
||||||
byte-identical `.mf` files (assuming file contents and metadata have not
|
|
||||||
changed).
|
|
||||||
|
|
||||||
## MIME Type
|
|
||||||
|
|
||||||
The recommended MIME type for `.mf` files is `application/octet-stream`. The
|
|
||||||
`.mf` file extension is the canonical identifier.
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
- Proto definition: [`mfer/mf.proto`](../mfer/mf.proto)
|
|
||||||
- Reference implementation:
|
|
||||||
[git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
|
||||||
@@ -1,28 +1,44 @@
|
|||||||
module sneak.berlin/go/mfer
|
module sneak.berlin/go/mfer
|
||||||
|
|
||||||
go 1.27.1
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/apex/log v1.9.0
|
||||||
github.com/davecgh/go-spew v1.1.1
|
github.com/davecgh/go-spew v1.1.1
|
||||||
github.com/dustin/go-humanize v1.1.0
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/klauspost/compress v1.20.1
|
github.com/google/uuid v1.1.2
|
||||||
|
github.com/klauspost/compress v1.18.2
|
||||||
github.com/multiformats/go-multihash v0.2.3
|
github.com/multiformats/go-multihash v0.2.3
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/pterm/pterm v0.12.35
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/spf13/afero v1.8.0
|
||||||
github.com/urfave/cli/v3 v3.14.0
|
github.com/stretchr/testify v1.8.1
|
||||||
golang.org/x/term v0.46.0
|
github.com/urfave/cli/v2 v2.23.6
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.28.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
github.com/atomicgo/cursor v0.0.1 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/mr-tron/base58 v1.3.0 // indirect
|
github.com/fatih/color v1.7.0 // indirect
|
||||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
github.com/gookit/color v1.4.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.8 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||||
|
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||||
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
|
github.com/multiformats/go-varint v0.0.6 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/sys v0.1.0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||||
|
golang.org/x/text v0.3.6 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
lukechampine.com/blake3 v1.1.6 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,40 +1,568 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
|
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||||
|
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||||
|
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||||
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
|
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
|
||||||
|
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
|
||||||
|
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
|
||||||
|
github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
|
||||||
|
github.com/MarvinJWendt/testza v0.2.12 h1:/PRp/BF+27t2ZxynTiqj0nyND5PbOtfJS0SuTuxmgeg=
|
||||||
|
github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
|
||||||
|
github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0=
|
||||||
|
github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA=
|
||||||
|
github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=
|
||||||
|
github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE=
|
||||||
|
github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=
|
||||||
|
github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU=
|
||||||
|
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||||
|
github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||||
|
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||||
|
github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
|
||||||
|
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||||
|
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
|
||||||
|
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
|
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
|
||||||
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
|
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
|
||||||
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
|
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||||
github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI=
|
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
|
||||||
github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI=
|
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
|
||||||
|
github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
|
||||||
|
github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE=
|
||||||
|
github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU=
|
||||||
|
github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
|
||||||
|
github.com/pterm/pterm v0.12.35 h1:A/vHwDM+WByn0sTPlpL2L6kOTy12xqZuwNFMF/NlA+U=
|
||||||
|
github.com/pterm/pterm v0.12.35/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
|
github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||||
|
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=
|
||||||
|
github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=
|
||||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
||||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
|
||||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc=
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
|
||||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
|
||||||
|
github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
|
||||||
|
github.com/urfave/cli/v2 v2.23.6 h1:iWmtKD+prGo1nKUtLO0Wg4z9esfBM4rAV4QRLQiEmJ4=
|
||||||
|
github.com/urfave/cli/v2 v2.23.6/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||||
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
|
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||||
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||||
|
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||||
|
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||||
|
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||||
|
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
|
||||||
|
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// Package bork defines the sentinel errors used by the manifest
|
|
||||||
// reader and writer.
|
|
||||||
package bork
|
package bork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrMissingMagic indicates the input lacks the manifest magic bytes.
|
ErrMissingMagic = errors.New("missing magic bytes in file")
|
||||||
ErrMissingMagic = errors.New("missing magic bytes in file")
|
|
||||||
// ErrFileTruncated indicates the input ended before the expected length.
|
|
||||||
ErrFileTruncated = errors.New("file/stream is truncated abnormally")
|
ErrFileTruncated = errors.New("file/stream is truncated abnormally")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func Newf(format string, args ...interface{}) error {
|
||||||
|
return fmt.Errorf(format, args...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package bork_test
|
package bork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"sneak.berlin/go/mfer/internal/bork"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuild(t *testing.T) {
|
func TestBuild(t *testing.T) {
|
||||||
t.Parallel()
|
assert.NotNil(t, ErrMissingMagic)
|
||||||
assert.Error(t, bork.ErrMissingMagic)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-301
@@ -1,360 +1,187 @@
|
|||||||
// Package cli implements the mfer command-line interface.
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fingerprintHexLen is the length of a full GPG key fingerprint in hex
|
// findManifest looks for a manifest file in the given directory.
|
||||||
// characters.
|
// It checks for index.mf and .index.mf, returning the first one found.
|
||||||
const fingerprintHexLen = 40
|
|
||||||
|
|
||||||
var (
|
|
||||||
// errNoManifestFound indicates no manifest file was found in the
|
|
||||||
// searched directory.
|
|
||||||
errNoManifestFound = errors.New("no manifest found")
|
|
||||||
// errInvalidFingerprint indicates a malformed --require-signature
|
|
||||||
// fingerprint argument. The length is spliced in from
|
|
||||||
// fingerprintHexLen so the two cannot drift apart.
|
|
||||||
errInvalidFingerprint = errors.New(
|
|
||||||
"invalid fingerprint: must be exactly " +
|
|
||||||
strconv.Itoa(fingerprintHexLen) + " hex characters")
|
|
||||||
// errManifestNotSigned indicates a signature was required but the
|
|
||||||
// manifest is unsigned. It is wrapped mid-sentence so that the
|
|
||||||
// rendered message stays exactly as mfer has always printed it.
|
|
||||||
errManifestNotSigned = errors.New("manifest is not signed")
|
|
||||||
// errSignerMismatch indicates the embedded signing key fingerprint
|
|
||||||
// does not match the required signer. Its text is the mid-sentence
|
|
||||||
// fragment of the rendered message, which users grep for in CI and
|
|
||||||
// which must therefore not change; match it with errors.Is rather
|
|
||||||
// than by reading it.
|
|
||||||
errSignerMismatch = errors.New("does not match required")
|
|
||||||
)
|
|
||||||
|
|
||||||
// safeUint64 converts a non-negative int64 to uint64, clamping negative
|
|
||||||
// values to zero.
|
|
||||||
func safeUint64(n int64) uint64 {
|
|
||||||
if n < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint64(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeRateUint64 converts a bytes-per-second rate to uint64 for display.
|
|
||||||
//
|
|
||||||
// A rate is computed as bytes/elapsed, so it is +Inf when the elapsed
|
|
||||||
// time rounds to zero and NaN when zero bytes were processed in zero
|
|
||||||
// time. Neither has a defined conversion to uint64, and on amd64 +Inf
|
|
||||||
// converts to a number that renders as "8.0 EiB/s"; both display as zero
|
|
||||||
// instead.
|
|
||||||
func safeRateUint64(rate float64) uint64 {
|
|
||||||
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if rate >= math.MaxUint64 {
|
|
||||||
return math.MaxUint64
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint64(rate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findManifest returns the path of the manifest with the default name in
|
|
||||||
// dir, or an error if there is none.
|
|
||||||
func findManifest(fs afero.Fs, dir string) (string, error) {
|
func findManifest(fs afero.Fs, dir string) (string, error) {
|
||||||
path := filepath.Join(dir, defaultManifestName)
|
candidates := []string{"index.mf", ".index.mf"}
|
||||||
|
for _, name := range candidates {
|
||||||
exists, err := afero.Exists(fs, path)
|
path := filepath.Join(dir, name)
|
||||||
if err != nil {
|
exists, err := afero.Exists(fs, path)
|
||||||
return "", err
|
if err != nil {
|
||||||
}
|
return "", err
|
||||||
|
}
|
||||||
if !exists {
|
if exists {
|
||||||
return "", fmt.Errorf("%w in %s (looked for %s)",
|
return path, nil
|
||||||
errNoManifestFound, dir, defaultManifestName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchManifestToTemp downloads a manifest URL to a temporary file and
|
|
||||||
// returns the temp file path. The caller is responsible for removing it.
|
|
||||||
func (mfa *CLIApp) fetchManifestToTemp(
|
|
||||||
ctx context.Context, url string,
|
|
||||||
) (string, error) {
|
|
||||||
rc, fetchErr := mfa.openManifestReader(ctx, url)
|
|
||||||
if fetchErr != nil {
|
|
||||||
return "", fetchErr
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
|
||||||
if tmpErr != nil {
|
|
||||||
_ = rc.Close()
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to create temp file: %w", tmpErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
_, cpErr := io.Copy(tmpFile, rc)
|
|
||||||
_ = rc.Close()
|
|
||||||
_ = tmpFile.Close()
|
|
||||||
|
|
||||||
if cpErr != nil {
|
|
||||||
_ = mfa.Fs.Remove(tmpPath)
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to download manifest: %w", cpErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tmpPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
|
||||||
// against the manifest's embedded signing key.
|
|
||||||
func verifyRequiredSigner(
|
|
||||||
ctx context.Context, chk *mfer.Checker, requiredSigner string,
|
|
||||||
) error {
|
|
||||||
// Validate fingerprint format: must be exactly 40 hex characters
|
|
||||||
if len(requiredSigner) != fingerprintHexLen {
|
|
||||||
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := hex.DecodeString(requiredSigner)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !chk.IsSigned() {
|
|
||||||
return fmt.Errorf("%w, but signature from %s is required",
|
|
||||||
errManifestNotSigned, requiredSigner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract fingerprint from the embedded public key (not from the
|
|
||||||
// signer field). This validates the key is importable and gets its
|
|
||||||
// actual fingerprint.
|
|
||||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"failed to extract fingerprint from embedded signing key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare fingerprints - must be exact match (case-insensitive)
|
|
||||||
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
|
||||||
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
|
||||||
embeddedFP, errSignerMismatch, requiredSigner)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportCheckProgress renders progress updates until the channel closes.
|
|
||||||
func reportCheckProgress(progress <-chan mfer.CheckStatus, wg *sync.WaitGroup) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for status := range progress {
|
|
||||||
if status.ETA > 0 {
|
|
||||||
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
|
|
||||||
status.CheckedFiles,
|
|
||||||
status.TotalFiles,
|
|
||||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
|
||||||
status.ETA.Round(time.Second),
|
|
||||||
status.Failures)
|
|
||||||
} else {
|
|
||||||
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
|
|
||||||
status.CheckedFiles,
|
|
||||||
status.TotalFiles,
|
|
||||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
|
||||||
status.Failures)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "", fmt.Errorf("no manifest found in %s (looked for index.mf and .index.mf)", dir)
|
||||||
log.ProgressDone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// countCheckFailures consumes check results, counting and logging
|
func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||||
// failures, then closes done.
|
|
||||||
func countCheckFailures(
|
|
||||||
results <-chan mfer.Result, failures *int64, done chan<- struct{},
|
|
||||||
) {
|
|
||||||
for result := range results {
|
|
||||||
if result.Status != mfer.StatusOK {
|
|
||||||
*failures++
|
|
||||||
|
|
||||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
|
||||||
} else {
|
|
||||||
log.Verbosef("%s: %s", result.Status, result.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close(done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findExtraFiles reports files present on disk but absent from the
|
|
||||||
// manifest, and anything the search cannot read: each is a failure under
|
|
||||||
// --no-extra-files, otherwise a warning.
|
|
||||||
func findExtraFiles(
|
|
||||||
ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64,
|
|
||||||
) error {
|
|
||||||
extraResults := make(chan mfer.Result, 1)
|
|
||||||
extraDone := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for result := range extraResults {
|
|
||||||
if cmd.Bool("no-extra-files") {
|
|
||||||
*failures++
|
|
||||||
|
|
||||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
|
||||||
} else {
|
|
||||||
log.Warnf("%s: %s (%s)", result.Status, result.Path, result.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close(extraDone)
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := chk.FindExtraFiles(ctx, extraResults)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check for extra files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
<-extraDone
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCheck runs the manifest check with progress and result reporting
|
|
||||||
// and returns the number of failures.
|
|
||||||
func runCheck(
|
|
||||||
ctx context.Context, cmd *cli.Command, chk *mfer.Checker, showProgress bool,
|
|
||||||
) (int64, error) {
|
|
||||||
// Set up results channel
|
|
||||||
results := make(chan mfer.Result, 1)
|
|
||||||
|
|
||||||
// Set up progress channel
|
|
||||||
var (
|
|
||||||
progress chan mfer.CheckStatus
|
|
||||||
progressWg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
if showProgress {
|
|
||||||
progress = make(chan mfer.CheckStatus, 1)
|
|
||||||
|
|
||||||
progressWg.Add(1)
|
|
||||||
|
|
||||||
go reportCheckProgress(progress, &progressWg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process results in a goroutine
|
|
||||||
var failures int64
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
go countCheckFailures(results, &failures, done)
|
|
||||||
|
|
||||||
// Run check
|
|
||||||
err := chk.Check(ctx, results, progress)
|
|
||||||
|
|
||||||
progressWg.Wait()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("check failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for results processing to complete
|
|
||||||
<-done
|
|
||||||
|
|
||||||
err = findExtraFiles(ctx, cmd, chk, &failures)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return failures, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) checkManifestOperation(
|
|
||||||
ctx context.Context, cmd *cli.Command,
|
|
||||||
) error {
|
|
||||||
log.Debug("checkManifestOperation()")
|
log.Debug("checkManifestOperation()")
|
||||||
|
|
||||||
manifestPath, err := mfa.resolveManifestArg(cmd)
|
manifestPath, err := mfa.resolveManifestArg(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check: %w", err)
|
return fmt.Errorf("check: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL manifests need to be downloaded to a temp file for the checker
|
// URL manifests need to be downloaded to a temp file for the checker
|
||||||
if isHTTPURL(manifestPath) {
|
if isHTTPURL(manifestPath) {
|
||||||
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
|
rc, fetchErr := mfa.openManifestReader(manifestPath)
|
||||||
|
if fetchErr != nil {
|
||||||
|
return fmt.Errorf("check: %w", fetchErr)
|
||||||
|
}
|
||||||
|
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
||||||
if tmpErr != nil {
|
if tmpErr != nil {
|
||||||
return fmt.Errorf("check: %w", tmpErr)
|
_ = rc.Close()
|
||||||
|
return fmt.Errorf("check: failed to create temp file: %w", tmpErr)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
_, cpErr := io.Copy(tmpFile, rc)
|
||||||
|
_ = rc.Close()
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
if cpErr != nil {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("check: failed to download manifest: %w", cpErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
||||||
|
|
||||||
manifestPath = tmpPath
|
manifestPath = tmpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
basePath := cmd.String("base")
|
basePath := ctx.String("base")
|
||||||
showProgress := cmd.Bool("progress")
|
showProgress := ctx.Bool("progress")
|
||||||
|
|
||||||
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
||||||
|
|
||||||
// Create checker
|
// Create checker
|
||||||
//nolint:contextcheck // mfer loads a manifest without a context
|
chk, err := mfer.NewChecker(manifestPath, basePath, mfa.Fs)
|
||||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
|
||||||
ManifestPath: manifestPath,
|
|
||||||
BasePath: basePath,
|
|
||||||
Fs: mfa.Fs,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load manifest: %w", err)
|
return fmt.Errorf("failed to load manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check signature requirement
|
// Check signature requirement
|
||||||
requiredSigner := cmd.String(flagRequireSignature)
|
requiredSigner := ctx.String("require-signature")
|
||||||
if requiredSigner != "" {
|
if requiredSigner != "" {
|
||||||
err = verifyRequiredSigner(ctx, chk, requiredSigner)
|
// Validate fingerprint format: must be exactly 40 hex characters
|
||||||
if err != nil {
|
if len(requiredSigner) != 40 {
|
||||||
return err
|
return fmt.Errorf("invalid fingerprint: must be exactly 40 hex characters, got %d", len(requiredSigner))
|
||||||
}
|
}
|
||||||
|
if _, err := hex.DecodeString(requiredSigner); err != nil {
|
||||||
|
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !chk.IsSigned() {
|
||||||
|
return fmt.Errorf("manifest is not signed, but signature from %s is required", requiredSigner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract fingerprint from the embedded public key (not from the signer field)
|
||||||
|
// This validates the key is importable and gets its actual fingerprint
|
||||||
|
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract fingerprint from embedded signing key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fingerprints - must be exact match (case-insensitive)
|
||||||
|
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
||||||
|
return fmt.Errorf("embedded signing key fingerprint %s does not match required %s", embeddedFP, requiredSigner)
|
||||||
|
}
|
||||||
|
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("manifest contains %d files, %s", chk.FileCount(),
|
log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())))
|
||||||
humanize.IBytes(safeUint64(int64(chk.TotalBytes()))))
|
|
||||||
|
|
||||||
failures, err := runCheck(ctx, cmd, chk, showProgress)
|
// Set up results channel
|
||||||
|
results := make(chan mfer.Result, 1)
|
||||||
|
|
||||||
|
// Set up progress channel
|
||||||
|
var progress chan mfer.CheckStatus
|
||||||
|
if showProgress {
|
||||||
|
progress = make(chan mfer.CheckStatus, 1)
|
||||||
|
go func() {
|
||||||
|
for status := range progress {
|
||||||
|
if status.ETA > 0 {
|
||||||
|
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
|
||||||
|
status.CheckedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||||
|
status.ETA.Round(time.Second),
|
||||||
|
status.Failures)
|
||||||
|
} else {
|
||||||
|
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
|
||||||
|
status.CheckedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||||
|
status.Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ProgressDone()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process results in a goroutine
|
||||||
|
var failures int64
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for result := range results {
|
||||||
|
if result.Status != mfer.StatusOK {
|
||||||
|
failures++
|
||||||
|
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
|
} else {
|
||||||
|
log.Verbosef("%s: %s", result.Status, result.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Run check
|
||||||
|
err = chk.Check(ctx.Context, results, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("check failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for results processing to complete
|
||||||
|
<-done
|
||||||
|
|
||||||
|
// Check for extra files if requested
|
||||||
|
if ctx.Bool("no-extra-files") {
|
||||||
|
extraResults := make(chan mfer.Result, 1)
|
||||||
|
extraDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for result := range extraResults {
|
||||||
|
failures++
|
||||||
|
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
|
}
|
||||||
|
close(extraDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = chk.FindExtraFiles(ctx.Context, extraResults)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for extra files: %w", err)
|
||||||
|
}
|
||||||
|
<-extraDone
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(mfa.startupTime).Seconds()
|
elapsed := time.Since(mfa.startupTime).Seconds()
|
||||||
|
|
||||||
rate := float64(chk.TotalBytes()) / elapsed
|
rate := float64(chk.TotalBytes()) / elapsed
|
||||||
if failures == 0 {
|
if failures == 0 {
|
||||||
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK",
|
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)))
|
||||||
chk.FileCount(), humanize.IBytes(safeUint64(int64(chk.TotalBytes()))),
|
|
||||||
elapsed, humanize.IBytes(safeRateUint64(rate)))
|
|
||||||
} else {
|
} else {
|
||||||
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed",
|
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)), failures)
|
||||||
chk.FileCount(), humanize.IBytes(safeUint64(int64(chk.TotalBytes()))),
|
|
||||||
elapsed, humanize.IBytes(safeRateUint64(rate)), failures)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if failures > 0 {
|
if failures > 0 {
|
||||||
|
|||||||
+7
-11
@@ -7,18 +7,15 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NoColor disables colored output when set. Automatically true if the
|
// NO_COLOR disables colored output when set. Automatically true if the
|
||||||
// NO_COLOR environment variable is present (per https://no-color.org/).
|
// NO_COLOR environment variable is present (per https://no-color.org/).
|
||||||
//
|
var NO_COLOR bool
|
||||||
//nolint:gochecknoglobals // process-wide setting derived from the environment
|
|
||||||
var NoColor = noColorEnvSet()
|
|
||||||
|
|
||||||
// noColorEnvSet reports whether the NO_COLOR environment variable is
|
func init() {
|
||||||
// present.
|
NO_COLOR = false
|
||||||
func noColorEnvSet() bool {
|
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
||||||
_, exists := os.LookupEnv("NO_COLOR")
|
NO_COLOR = true
|
||||||
|
}
|
||||||
return exists
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunOptions contains all configuration for running the CLI application.
|
// RunOptions contains all configuration for running the CLI application.
|
||||||
@@ -67,6 +64,5 @@ func RunWithOptions(opts *RunOptions) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.run(opts.Args)
|
m.run(opts.Args)
|
||||||
|
|
||||||
return m.exitCode
|
return m.exitCode
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-1033
File diff suppressed because it is too large
Load Diff
@@ -1,383 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
urfcli "github.com/urfave/cli/v3"
|
|
||||||
"sneak.berlin/go/mfer/mfer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests pin the exact rendered text of the CLI's user-visible error
|
|
||||||
// messages. The messages are grepped for in CI pipelines and quoted in bug
|
|
||||||
// reports, so a reword is a deliberate change, never a refactoring side
|
|
||||||
// effect.
|
|
||||||
//
|
|
||||||
// Every case drives the real function that emits the message and asserts on
|
|
||||||
// what it returns. No production format string is restated here: a test that
|
|
||||||
// only re-rendered a copied format string would keep passing after the real
|
|
||||||
// message changed, which is exactly the regression these tests exist to
|
|
||||||
// catch.
|
|
||||||
|
|
||||||
// Full 40-hex fingerprints used where a message embeds one.
|
|
||||||
const (
|
|
||||||
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
||||||
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runLocked runs fn while holding runMu, so operations that write to the
|
|
||||||
// process-global logger do not race the other CLI runs.
|
|
||||||
func runLocked(fn func() error) error {
|
|
||||||
runMu.Lock()
|
|
||||||
defer runMu.Unlock()
|
|
||||||
|
|
||||||
return fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// unsignedChecker builds a Checker over a freshly scanned, unsigned manifest.
|
|
||||||
func unsignedChecker(t *testing.T) *mfer.Checker {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
|
||||||
|
|
||||||
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: fs})
|
|
||||||
require.NoError(t, s.EnumeratePath("/d", nil))
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/d/index.mf", buf.Bytes(), 0o644))
|
|
||||||
|
|
||||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
|
||||||
ManifestPath: "/d/index.mf",
|
|
||||||
BasePath: "/d",
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, chk.IsSigned())
|
|
||||||
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoManifestFoundMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
|
|
||||||
require.ErrorIs(t, err, errNoManifestFound)
|
|
||||||
assert.EqualError(t, err,
|
|
||||||
"no manifest found in /tmp/x (looked for index.mf)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("invalid fingerprint length", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := verifyRequiredSigner(context.Background(),
|
|
||||||
unsignedChecker(t), "12345678")
|
|
||||||
require.ErrorIs(t, err, errInvalidFingerprint)
|
|
||||||
assert.EqualError(t, err,
|
|
||||||
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("manifest not signed", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := verifyRequiredSigner(context.Background(),
|
|
||||||
unsignedChecker(t), msgFpA)
|
|
||||||
require.ErrorIs(t, err, errManifestNotSigned)
|
|
||||||
assert.EqualError(t, err,
|
|
||||||
"manifest is not signed, but signature from "+msgFpA+" is required")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
|
|
||||||
// manifest. The embedded fingerprint is whatever the generated key produced,
|
|
||||||
// so it is read back from the checker and substituted into the expected
|
|
||||||
// string; the required signer is a fixed value that cannot match it. Requires
|
|
||||||
// gpg and is skipped where it is absent, as the other signing tests are.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
|
|
||||||
func TestSignerMismatchMessage(t *testing.T) {
|
|
||||||
chk := signedChecker(t,
|
|
||||||
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
|
|
||||||
|
|
||||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = verifyRequiredSigner(context.Background(), chk, msgFpB)
|
|
||||||
require.ErrorIs(t, err, errSignerMismatch)
|
|
||||||
assert.EqualError(t, err,
|
|
||||||
"embedded signing key fingerprint "+embeddedFP+
|
|
||||||
" does not match required "+msgFpB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// signedManifest returns a manifest of files signed by a throwaway GPG key
|
|
||||||
// generated in a temporary GNUPGHOME, which it leaves set for the rest of
|
|
||||||
// the test.
|
|
||||||
func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, err := exec.LookPath("gpg")
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("gpg not installed, skipping signing test")
|
|
||||||
}
|
|
||||||
|
|
||||||
gpgHome := t.TempDir()
|
|
||||||
params := "%no-protection\n" +
|
|
||||||
"Key-Type: RSA\nKey-Length: 2048\n" +
|
|
||||||
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n" +
|
|
||||||
"Expire-Date: 0\n%commit\n"
|
|
||||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
|
||||||
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
|
|
||||||
|
|
||||||
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
|
||||||
cmd := exec.CommandContext(context.Background(), "gpg",
|
|
||||||
"--batch", "--gen-key", paramsFile)
|
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("failed to generate test GPG key: %v: %s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
|
||||||
|
|
||||||
b := mfer.NewBuilder()
|
|
||||||
b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
|
|
||||||
|
|
||||||
for path, content := range files {
|
|
||||||
_, err = b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)),
|
|
||||||
mfer.ModTime{}, 0, bytes.NewReader(content), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
|
||||||
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// signedChecker builds a Checker over manifest, a signed manifest.
|
|
||||||
func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644))
|
|
||||||
|
|
||||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
|
||||||
ManifestPath: "/index.mf",
|
|
||||||
BasePath: "/",
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, chk.IsSigned())
|
|
||||||
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPathDoesNotExistMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
||||||
cmd := &urfcli.Command{
|
|
||||||
Name: cmdGenerate,
|
|
||||||
Action: func(_ context.Context, c *urfcli.Command) error {
|
|
||||||
_, err := mfa.collectInputPaths(c.Args())
|
|
||||||
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := cmd.Run(context.Background(), []string{cmdGenerate, "nope"})
|
|
||||||
require.ErrorIs(t, err, errPathNotExist)
|
|
||||||
assert.EqualError(t, err, "path does not exist: nope")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOutputFileExistsMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644))
|
|
||||||
|
|
||||||
mfa := &CLIApp{Fs: fs}
|
|
||||||
cmd := &urfcli.Command{
|
|
||||||
Name: cmdGenerate,
|
|
||||||
Flags: []urfcli.Flag{
|
|
||||||
&urfcli.StringFlag{Name: "output"},
|
|
||||||
&urfcli.BoolFlag{Name: "force"},
|
|
||||||
},
|
|
||||||
Action: mfa.generateManifestOperation,
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateManifestOperation writes to the process-global logger during
|
|
||||||
// enumeration, so serialize with the other CLI runs.
|
|
||||||
err := runLocked(func() error {
|
|
||||||
return cmd.Run(context.Background(),
|
|
||||||
[]string{cmdGenerate, "--output", "/out.mf", "/d"})
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, errOutputExists)
|
|
||||||
assert.EqualError(t, err,
|
|
||||||
"output file /out.mf already exists (use --force to overwrite)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnknownCommandMessage drives the root command's action. run only logs
|
|
||||||
// the error that action returns, so the test lets run build the app with no
|
|
||||||
// command given and then runs that same app on an unknown command to get the
|
|
||||||
// error itself.
|
|
||||||
func TestUnknownCommandMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mfa := &CLIApp{
|
|
||||||
appname: testApp,
|
|
||||||
Stdout: &bytes.Buffer{},
|
|
||||||
Stderr: &bytes.Buffer{},
|
|
||||||
Fs: afero.NewMemMapFs(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// run points the process-global logger at this app's output, so
|
|
||||||
// serialize with the other CLI runs.
|
|
||||||
err := runLocked(func() error {
|
|
||||||
mfa.run([]string{testApp})
|
|
||||||
|
|
||||||
return mfa.app.Run(context.Background(), []string{testApp, "bogus"})
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, errUnknownCommand)
|
|
||||||
assert.EqualError(t, err, `unknown command "bogus"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManifestLoaderHTTPStatusMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
||||||
|
|
||||||
_, err := mfa.openManifestReader(context.Background(), server.URL+"/foo.mf")
|
|
||||||
require.ErrorIs(t, err, errHTTPStatus)
|
|
||||||
assert.EqualError(t, err,
|
|
||||||
"failed to fetch "+server.URL+"/foo.mf: HTTP 404")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchManifestHTTPStatusMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
||||||
|
|
||||||
cmd := mfa.fetchCommand()
|
|
||||||
cmd.Action = mfa.fetchManifestOperation
|
|
||||||
|
|
||||||
// fetchManifestOperation logs to the process-global logger.
|
|
||||||
err := runLocked(func() error {
|
|
||||||
return cmd.Run(context.Background(), []string{cmdFetch, server.URL})
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, errHTTPStatus)
|
|
||||||
assert.EqualError(t, err, "failed to fetch manifest: HTTP 404")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// downloadFile logs each retry of the 500 to the process-global logger.
|
|
||||||
err := runLocked(func() error {
|
|
||||||
return downloadFile(context.Background(), testClient(), server.URL+"/x", ".", "x",
|
|
||||||
&mfer.MFFilePath{}, nil)
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, errHTTPStatus)
|
|
||||||
assert.EqualError(t, err, "HTTP 500")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestURLRequiredMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
||||||
cmd := &urfcli.Command{Name: cmdFetch, Action: mfa.fetchManifestOperation}
|
|
||||||
|
|
||||||
// fetchManifestOperation logs to the process-global logger.
|
|
||||||
err := runLocked(func() error {
|
|
||||||
return cmd.Run(context.Background(), []string{cmdFetch})
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, errURLRequired)
|
|
||||||
assert.EqualError(t, err, "URL argument required")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizePathMessages(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("empty", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := sanitizePath("")
|
|
||||||
require.ErrorIs(t, err, errEmptyPath)
|
|
||||||
assert.EqualError(t, err, "empty path")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("absolute", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := sanitizePath("/etc/passwd")
|
|
||||||
require.ErrorIs(t, err, errAbsolutePath)
|
|
||||||
assert.EqualError(t, err, "absolute path not allowed: /etc/passwd")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("traversal", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := sanitizePath("../x")
|
|
||||||
require.ErrorIs(t, err, errPathTraversal)
|
|
||||||
assert.EqualError(t, err, "path traversal not allowed: ../x")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSizeMismatchMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// finishDownload returns the size-mismatch error before it touches the
|
|
||||||
// paths, digest, or entry, so those can be zero here.
|
|
||||||
err := finishDownload("", "", "", 9, 10, nil, nil, nil, nil)
|
|
||||||
require.ErrorIs(t, err, errSizeMismatch)
|
|
||||||
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashMismatchMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A 32-byte digest that matches none of the (empty) manifest hashes.
|
|
||||||
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{})
|
|
||||||
require.ErrorIs(t, err, errHashMismatch)
|
|
||||||
require.NotErrorIs(t, err, errSizeMismatch)
|
|
||||||
assert.EqualError(t, err, "hash mismatch")
|
|
||||||
}
|
|
||||||
+14
-25
@@ -1,13 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,25 +17,20 @@ type ExportEntry struct {
|
|||||||
Hashes []string `json:"hashes"`
|
Hashes []string `json:"hashes"`
|
||||||
Mtime *string `json:"mtime,omitempty"`
|
Mtime *string `json:"mtime,omitempty"`
|
||||||
Ctime *string `json:"ctime,omitempty"`
|
Ctime *string `json:"ctime,omitempty"`
|
||||||
Mode string `json:"mode"` // octal, "0000" when none was recorded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mfa *CLIApp) exportManifestOperation(
|
func (mfa *CLIApp) exportManifestOperation(ctx *cli.Context) error {
|
||||||
ctx context.Context, cmd *cli.Command,
|
pathOrURL, err := mfa.resolveManifestArg(ctx)
|
||||||
) error {
|
|
||||||
pathOrURL, err := mfa.resolveManifestArg(cmd)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export: %w", err)
|
return fmt.Errorf("export: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
rc, err := mfa.openManifestReader(pathOrURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export: %w", err)
|
return fmt.Errorf("export: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rc.Close() }()
|
defer func() { _ = rc.Close() }()
|
||||||
|
|
||||||
//nolint:contextcheck // mfer loads a manifest without a context
|
|
||||||
manifest, err := mfer.NewManifestFromReader(rc)
|
manifest, err := mfer.NewManifestFromReader(rc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export: failed to parse manifest: %w", err)
|
return fmt.Errorf("export: failed to parse manifest: %w", err)
|
||||||
@@ -47,24 +41,21 @@ func (mfa *CLIApp) exportManifestOperation(
|
|||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
entry := ExportEntry{
|
entry := ExportEntry{
|
||||||
Path: f.GetPath(),
|
Path: f.Path,
|
||||||
Size: f.GetSize(),
|
Size: f.Size,
|
||||||
Hashes: make([]string, 0, len(f.GetHashes())),
|
Hashes: make([]string, 0, len(f.Hashes)),
|
||||||
Mode: fmt.Sprintf("%04o", f.GetMode()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, h := range f.GetHashes() {
|
for _, h := range f.Hashes {
|
||||||
entry.Hashes = append(entry.Hashes, hex.EncodeToString(h.GetMultiHash()))
|
entry.Hashes = append(entry.Hashes, hex.EncodeToString(h.MultiHash))
|
||||||
}
|
}
|
||||||
|
|
||||||
if mtime, ok := entryMtime(f); ok {
|
if f.Mtime != nil {
|
||||||
t := mtime.UTC().Format(time.RFC3339Nano)
|
t := time.Unix(f.Mtime.Seconds, int64(f.Mtime.Nanos)).UTC().Format(time.RFC3339Nano)
|
||||||
entry.Mtime = &t
|
entry.Mtime = &t
|
||||||
}
|
}
|
||||||
|
if f.Ctime != nil {
|
||||||
if f.GetCtime() != nil {
|
t := time.Unix(f.Ctime.Seconds, int64(f.Ctime.Nanos)).UTC().Format(time.RFC3339Nano)
|
||||||
t := time.Unix(f.GetCtime().GetSeconds(), int64(f.GetCtime().GetNanos())).
|
|
||||||
UTC().Format(time.RFC3339Nano)
|
|
||||||
entry.Ctime = &t
|
entry.Ctime = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +64,7 @@ func (mfa *CLIApp) exportManifestOperation(
|
|||||||
|
|
||||||
enc := json.NewEncoder(mfa.Stdout)
|
enc := json.NewEncoder(mfa.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(entries); err != nil {
|
||||||
err = enc.Encode(entries)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("export: failed to encode JSON: %w", err)
|
return fmt.Errorf("export: failed to encode JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-36
@@ -14,12 +14,9 @@ import (
|
|||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testCmdExport = "export"
|
|
||||||
|
|
||||||
// buildTestManifest creates a manifest from in-memory files and returns its bytes.
|
// buildTestManifest creates a manifest from in-memory files and returns its bytes.
|
||||||
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sourceFs := afero.NewMemMapFs()
|
sourceFs := afero.NewMemMapFs()
|
||||||
for path, content := range files {
|
for path, content := range files {
|
||||||
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
||||||
@@ -31,15 +28,11 @@ func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
|||||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExportManifestOperation(t *testing.T) {
|
func TestExportManifestOperation(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testFiles := map[string][]byte{
|
testFiles := map[string][]byte{
|
||||||
"hello.txt": []byte("Hello, World!"),
|
"hello.txt": []byte("Hello, World!"),
|
||||||
"sub/file.txt": []byte("nested content"),
|
"sub/file.txt": []byte("nested content"),
|
||||||
@@ -51,10 +44,9 @@ func TestExportManifestOperation(t *testing.T) {
|
|||||||
require.NoError(t, afero.WriteFile(fs, "/test.mf", manifestData, 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/test.mf", manifestData, 0o644))
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := RunWithOptions(&RunOptions{
|
||||||
exitCode := runCLI(&RunOptions{
|
Appname: "mfer",
|
||||||
Appname: testApp,
|
Args: []string{"mfer", "export", "/test.mf"},
|
||||||
Args: []string{testApp, testCmdExport, "/test.mf"},
|
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
@@ -72,33 +64,28 @@ func TestExportManifestOperation(t *testing.T) {
|
|||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
pathSet[e.Path] = true
|
pathSet[e.Path] = true
|
||||||
assert.NotEmpty(t, e.Hashes, "entry %s should have hashes", e.Path)
|
assert.NotEmpty(t, e.Hashes, "entry %s should have hashes", e.Path)
|
||||||
assert.Positive(t, e.Size, "entry %s should have positive size", e.Path)
|
assert.Greater(t, e.Size, int64(0), "entry %s should have positive size", e.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.True(t, pathSet["hello.txt"])
|
assert.True(t, pathSet["hello.txt"])
|
||||||
assert.True(t, pathSet["sub/file.txt"])
|
assert.True(t, pathSet["sub/file.txt"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExportFromHTTPURL(t *testing.T) {
|
func TestExportFromHTTPURL(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testFiles := map[string][]byte{
|
testFiles := map[string][]byte{
|
||||||
"a.txt": []byte("aaa"),
|
"a.txt": []byte("aaa"),
|
||||||
}
|
}
|
||||||
manifestData := buildTestManifest(t, testFiles)
|
manifestData := buildTestManifest(t, testFiles)
|
||||||
|
|
||||||
server := httptest.NewServer(
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
_, _ = w.Write(manifestData)
|
||||||
_, _ = w.Write(manifestData)
|
}))
|
||||||
}))
|
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := RunWithOptions(&RunOptions{
|
||||||
exitCode := runCLI(&RunOptions{
|
Appname: "mfer",
|
||||||
Appname: testApp,
|
Args: []string{"mfer", "export", server.URL + "/index.mf"},
|
||||||
Args: []string{testApp, testCmdExport, server.URL + "/index.mf"},
|
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
@@ -114,25 +101,21 @@ func TestExportFromHTTPURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestListFromHTTPURL(t *testing.T) {
|
func TestListFromHTTPURL(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testFiles := map[string][]byte{
|
testFiles := map[string][]byte{
|
||||||
"one.txt": []byte("1"),
|
"one.txt": []byte("1"),
|
||||||
"two.txt": []byte("22"),
|
"two.txt": []byte("22"),
|
||||||
}
|
}
|
||||||
manifestData := buildTestManifest(t, testFiles)
|
manifestData := buildTestManifest(t, testFiles)
|
||||||
|
|
||||||
server := httptest.NewServer(
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
_, _ = w.Write(manifestData)
|
||||||
_, _ = w.Write(manifestData)
|
}))
|
||||||
}))
|
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := RunWithOptions(&RunOptions{
|
||||||
exitCode := runCLI(&RunOptions{
|
Appname: "mfer",
|
||||||
Appname: testApp,
|
Args: []string{"mfer", "list", server.URL + "/index.mf"},
|
||||||
Args: []string{testApp, cmdList, server.URL + "/index.mf"},
|
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
@@ -146,8 +129,6 @@ func TestListFromHTTPURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIsHTTPURL(t *testing.T) {
|
func TestIsHTTPURL(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.True(t, isHTTPURL("http://example.com/manifest.mf"))
|
assert.True(t, isHTTPURL("http://example.com/manifest.mf"))
|
||||||
assert.True(t, isHTTPURL("https://example.com/manifest.mf"))
|
assert.True(t, isHTTPURL("https://example.com/manifest.mf"))
|
||||||
assert.False(t, isHTTPURL("/local/path.mf"))
|
assert.False(t, isHTTPURL("/local/path.mf"))
|
||||||
|
|||||||
+155
-742
File diff suppressed because it is too large
Load Diff
+140
-1356
File diff suppressed because it is too large
Load Diff
+251
-523
@@ -1,37 +1,21 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/multiformats/go-multihash"
|
"github.com/multiformats/go-multihash"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
// hashBufSize is the read buffer size used when hashing files.
|
|
||||||
hashBufSize = 64 * 1024
|
|
||||||
|
|
||||||
// scanProgressInterval is how many scanned files pass between
|
|
||||||
// progress updates.
|
|
||||||
scanProgressInterval = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
// errEntryMissingMtime indicates a manifest entry that carries no
|
|
||||||
// modification time where one is required to carry it forward unchanged.
|
|
||||||
var errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
|
||||||
|
|
||||||
// FreshenStatus contains progress information for the freshen operation.
|
// FreshenStatus contains progress information for the freshen operation.
|
||||||
type FreshenStatus struct {
|
type FreshenStatus struct {
|
||||||
Phase string // "scan" or "hash"
|
Phase string // "scan" or "hash"
|
||||||
@@ -48,436 +32,46 @@ type freshenEntry struct {
|
|||||||
path string
|
path string
|
||||||
size int64
|
size int64
|
||||||
mtime time.Time
|
mtime time.Time
|
||||||
mode fs.FileMode // mode to record, 0 for none
|
|
||||||
needsHash bool // true if new or changed
|
needsHash bool // true if new or changed
|
||||||
existing *mfer.MFFilePath // existing manifest entry if unchanged
|
existing *mfer.MFFilePath // existing manifest entry if unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
// freshenScanner walks the filesystem and compares it against the
|
func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||||
// entries of an existing manifest.
|
log.Debug("freshenManifestOperation()")
|
||||||
type freshenScanner struct {
|
|
||||||
fs afero.Fs
|
|
||||||
absBase string
|
|
||||||
excluded []fs.FileInfo // files left out of the listing
|
|
||||||
includeDotfiles bool
|
|
||||||
followSymlinks bool
|
|
||||||
includePermissions bool
|
|
||||||
showProgress bool
|
|
||||||
existingByPath map[string]*mfer.MFFilePath
|
|
||||||
|
|
||||||
entries []*freshenEntry
|
basePath := ctx.String("base")
|
||||||
scanCount int64
|
showProgress := ctx.Bool("progress")
|
||||||
changed int64
|
includeDotfiles := ctx.Bool("include-dotfiles")
|
||||||
added int64
|
followSymlinks := ctx.Bool("follow-symlinks")
|
||||||
unchanged int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSymlink resolves a symlink to its target's FileInfo. The
|
// Find manifest file
|
||||||
// second return value is false when the entry should be skipped.
|
var manifestPath string
|
||||||
func (s *freshenScanner) resolveSymlink(path string) (fs.FileInfo, bool) {
|
var err error
|
||||||
if !s.followSymlinks {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
realPath, err := filepath.EvalSymlinks(path)
|
if ctx.Args().Len() > 0 {
|
||||||
if err != nil {
|
arg := ctx.Args().Get(0)
|
||||||
return nil, false // Skip broken symlinks
|
info, statErr := mfa.Fs.Stat(arg)
|
||||||
}
|
if statErr == nil && info.IsDir() {
|
||||||
|
manifestPath, err = findManifest(mfa.Fs, arg)
|
||||||
realInfo, err := s.fs.Stat(realPath)
|
if err != nil {
|
||||||
if err != nil || realInfo.IsDir() {
|
return fmt.Errorf("freshen: %w", err)
|
||||||
return nil, false
|
}
|
||||||
}
|
} else {
|
||||||
|
manifestPath = arg
|
||||||
return realInfo, true
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// recordEntry classifies a scanned file as changed, unchanged, or added
|
|
||||||
// relative to the existing manifest.
|
|
||||||
func (s *freshenScanner) recordEntry(relPath string, info fs.FileInfo) {
|
|
||||||
// A mode of 0 records 0000, which means none was recorded.
|
|
||||||
var mode fs.FileMode
|
|
||||||
if s.includePermissions {
|
|
||||||
mode = info.Mode().Perm()
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, inManifest := s.existingByPath[relPath]
|
|
||||||
if !inManifest {
|
|
||||||
s.added++
|
|
||||||
|
|
||||||
log.Verbosef("A %s", relPath)
|
|
||||||
s.entries = append(s.entries, &freshenEntry{
|
|
||||||
path: relPath,
|
|
||||||
size: info.Size(),
|
|
||||||
mtime: info.ModTime(),
|
|
||||||
mode: mode,
|
|
||||||
needsHash: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if changed (size, mtime, or the mode to record). An entry
|
|
||||||
// with no recorded mtime cannot be compared, so it counts as changed
|
|
||||||
// and gets re-hashed; silently treating the absent mtime as the Unix
|
|
||||||
// epoch would classify every such entry as changed without saying why.
|
|
||||||
existingMtime, haveMtime := entryMtime(existing)
|
|
||||||
if !haveMtime {
|
|
||||||
log.Debugf("%s: manifest entry has no mtime, treating as changed",
|
|
||||||
relPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !haveMtime || existing.GetSize() != info.Size() ||
|
|
||||||
!existingMtime.Equal(info.ModTime()) ||
|
|
||||||
fs.FileMode(existing.GetMode()) != mode {
|
|
||||||
s.changed++
|
|
||||||
|
|
||||||
log.Verbosef("M %s", relPath)
|
|
||||||
s.entries = append(s.entries, &freshenEntry{
|
|
||||||
path: relPath,
|
|
||||||
size: info.Size(),
|
|
||||||
mtime: info.ModTime(),
|
|
||||||
mode: mode,
|
|
||||||
needsHash: true,
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
s.unchanged++
|
manifestPath, err = findManifest(mfa.Fs, ".")
|
||||||
|
|
||||||
s.entries = append(s.entries, &freshenEntry{
|
|
||||||
path: relPath,
|
|
||||||
size: info.Size(),
|
|
||||||
mtime: info.ModTime(),
|
|
||||||
mode: mode,
|
|
||||||
needsHash: false,
|
|
||||||
existing: existing,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Mark as seen
|
|
||||||
delete(s.existingByPath, relPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// walk is the afero.Walk callback for the scan phase.
|
|
||||||
func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path
|
|
||||||
relPath, err := filepath.Rel(s.absBase, path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"freshen: failed to compute relative path for %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dotfiles
|
|
||||||
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
|
||||||
if info.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip directories
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle symlinks
|
|
||||||
if info.Mode()&fs.ModeSymlink != 0 {
|
|
||||||
realInfo, keep := s.resolveSymlink(path)
|
|
||||||
if !keep {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info = realInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, excluded := range s.excluded {
|
|
||||||
if os.SameFile(info, excluded) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.scanCount++
|
|
||||||
|
|
||||||
// Check against existing manifest
|
|
||||||
s.recordEntry(relPath, info)
|
|
||||||
|
|
||||||
// Report scan progress
|
|
||||||
if s.showProgress && s.scanCount%scanProgressInterval == 0 {
|
|
||||||
log.Progressf("Scanning: %d files found", s.scanCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveFreshenManifestPath determines the manifest path from the CLI
|
|
||||||
// arguments, searching directories for a manifest where needed.
|
|
||||||
func (mfa *CLIApp) resolveFreshenManifestPath(cmd *cli.Command) (string, error) {
|
|
||||||
if cmd.Args().Len() == 0 {
|
|
||||||
return findManifest(mfa.Fs, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
arg := cmd.Args().Get(0)
|
|
||||||
|
|
||||||
info, statErr := mfa.Fs.Stat(arg)
|
|
||||||
if statErr == nil && info.IsDir() {
|
|
||||||
return findManifest(mfa.Fs, arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return arg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// freshenHasher hashes changed and added files and feeds all entries to
|
|
||||||
// a manifest builder.
|
|
||||||
type freshenHasher struct {
|
|
||||||
fs afero.Fs
|
|
||||||
absBase string
|
|
||||||
showProgress bool
|
|
||||||
totalHashBytes int64
|
|
||||||
filesToHash int64
|
|
||||||
startHash time.Time
|
|
||||||
builder *mfer.Builder
|
|
||||||
|
|
||||||
hashedFiles int64
|
|
||||||
hashedBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportProgress renders hashing progress for the current byte count.
|
|
||||||
func (h *freshenHasher) reportProgress(n int64) {
|
|
||||||
if !h.showProgress {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentBytes := h.hashedBytes + n
|
|
||||||
elapsed := time.Since(h.startHash)
|
|
||||||
|
|
||||||
var (
|
|
||||||
rate float64
|
|
||||||
eta time.Duration
|
|
||||||
)
|
|
||||||
|
|
||||||
if elapsed > 0 && currentBytes > 0 {
|
|
||||||
rate = float64(currentBytes) / elapsed.Seconds()
|
|
||||||
|
|
||||||
remaining := h.totalHashBytes - currentBytes
|
|
||||||
if rate > 0 {
|
|
||||||
eta = time.Duration(float64(remaining)/rate) * time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if eta > 0 {
|
|
||||||
log.Progressf("Hashing: %d/%d files, %s/s, ETA %s",
|
|
||||||
h.hashedFiles, h.filesToHash, humanize.IBytes(safeRateUint64(rate)),
|
|
||||||
eta.Round(time.Second))
|
|
||||||
} else {
|
|
||||||
log.Progressf("Hashing: %d/%d files, %s/s",
|
|
||||||
h.hashedFiles, h.filesToHash, humanize.IBytes(safeRateUint64(rate)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processEntry hashes the entry if needed and adds it to the builder.
|
|
||||||
func (h *freshenHasher) processEntry(e *freshenEntry) error {
|
|
||||||
if !e.needsHash {
|
|
||||||
// Use existing entry
|
|
||||||
err := addExistingToBuilder(h.builder, e.existing)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
return fmt.Errorf("freshen: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to read and hash the file
|
|
||||||
absPath := filepath.Join(h.absBase, e.path)
|
|
||||||
|
|
||||||
f, err := h.fs.Open(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open %s: %w", e.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, bytesRead, err := hashFile(f, h.reportProgress)
|
|
||||||
_ = f.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to hash %s: %w", e.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.hashedBytes += bytesRead
|
|
||||||
h.hashedFiles++
|
|
||||||
|
|
||||||
// Add to builder with computed hash
|
|
||||||
err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFreshenedManifest writes the manifest atomically (write to a
|
|
||||||
// temp file, then rename over the target).
|
|
||||||
func writeFreshenedManifest(
|
|
||||||
ctx context.Context, afs afero.Fs, builder *mfer.Builder, manifestPath string,
|
|
||||||
) error {
|
|
||||||
tmpPath := manifestTempPath(manifestPath)
|
|
||||||
|
|
||||||
outFile, err := afs.Create(tmpPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = builder.Build(ctx, outFile)
|
|
||||||
_ = outFile.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
_ = afs.Remove(tmpPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to write manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename temp to final
|
|
||||||
err = afs.Rename(tmpPath, manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
_ = afs.Remove(tmpPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to rename manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFreshenBuilder constructs the manifest builder configured from CLI
|
|
||||||
// flags.
|
|
||||||
func newFreshenBuilder(cmd *cli.Command) *mfer.Builder {
|
|
||||||
builder := mfer.NewBuilder()
|
|
||||||
if cmd.Bool("include-timestamps") {
|
|
||||||
builder.SetIncludeTimestamps(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up signing options if sign-key is provided
|
|
||||||
if signKey := cmd.String("sign-key"); signKey != "" {
|
|
||||||
builder.SetSigningOptions(&mfer.SigningOptions{
|
|
||||||
KeyID: mfer.GPGKeyID(signKey),
|
|
||||||
})
|
|
||||||
log.Infof("signing manifest with GPG key: %s", signKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder
|
|
||||||
}
|
|
||||||
|
|
||||||
// freshenScan runs the scan phase against the loaded manifest entries
|
|
||||||
// and returns the populated scanner and the count of removed files.
|
|
||||||
func (mfa *CLIApp) freshenScan(
|
|
||||||
cmd *cli.Command, manifestPath, absBase string,
|
|
||||||
existingByPath map[string]*mfer.MFFilePath,
|
|
||||||
) (*freshenScanner, int64, error) {
|
|
||||||
log.Infof("scanning filesystem...")
|
|
||||||
|
|
||||||
startScan := time.Now()
|
|
||||||
showProgress := cmd.Bool("progress")
|
|
||||||
|
|
||||||
// Leave out the manifest and a temp file left by an interrupted run,
|
|
||||||
// as gen does. A path that cannot be stat'd, normally because no file
|
|
||||||
// is there, needs no leaving out.
|
|
||||||
var excluded []fs.FileInfo
|
|
||||||
|
|
||||||
for _, p := range []string{manifestPath, manifestTempPath(manifestPath)} {
|
|
||||||
info, err := mfa.Fs.Stat(p)
|
|
||||||
if err == nil {
|
|
||||||
excluded = append(excluded, info)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := &freshenScanner{
|
|
||||||
fs: mfa.Fs,
|
|
||||||
absBase: absBase,
|
|
||||||
excluded: excluded,
|
|
||||||
includeDotfiles: cmd.Bool("include-dotfiles"),
|
|
||||||
followSymlinks: cmd.Bool("follow-symlinks"),
|
|
||||||
includePermissions: cmd.Bool(flagIncludePermissions),
|
|
||||||
showProgress: showProgress,
|
|
||||||
existingByPath: existingByPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := afero.Walk(mfa.Fs, absBase, scanner.walk)
|
|
||||||
|
|
||||||
if showProgress {
|
|
||||||
log.ProgressDone()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("failed to scan filesystem: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining entries in existingByPath are removed files
|
|
||||||
removed := int64(len(existingByPath))
|
|
||||||
for path := range existingByPath {
|
|
||||||
log.Verbosef("D %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanDuration := time.Since(startScan)
|
|
||||||
log.Infof("scan complete in %s: %d unchanged, %d changed, %d added, %d removed",
|
|
||||||
scanDuration.Round(time.Millisecond), scanner.unchanged, scanner.changed,
|
|
||||||
scanner.added, removed)
|
|
||||||
|
|
||||||
return scanner, removed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashTotals returns the total byte count and file count of entries
|
|
||||||
// that need hashing.
|
|
||||||
func hashTotals(entries []*freshenEntry) (int64, int64) {
|
|
||||||
var (
|
|
||||||
totalHashBytes int64
|
|
||||||
filesToHash int64
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.needsHash {
|
|
||||||
totalHashBytes += e.size
|
|
||||||
filesToHash++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalHashBytes, filesToHash
|
|
||||||
}
|
|
||||||
|
|
||||||
// runFreshenHash processes every entry through the hasher, aborting if
|
|
||||||
// the context is canceled.
|
|
||||||
func runFreshenHash(
|
|
||||||
ctx context.Context, hasher *freshenHasher, entries []*freshenEntry,
|
|
||||||
) error {
|
|
||||||
for _, e := range entries {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hasher.processEntry(e)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadExistingEntries loads the manifest and indexes its file entries
|
|
||||||
// by path.
|
|
||||||
func (mfa *CLIApp) loadExistingEntries(
|
|
||||||
manifestPath string,
|
|
||||||
) (map[string]*mfer.MFFilePath, error) {
|
|
||||||
log.Infof("loading manifest from %s", manifestPath)
|
log.Infof("loading manifest from %s", manifestPath)
|
||||||
|
|
||||||
// Load existing manifest
|
// Load existing manifest
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
manifest, err := mfer.NewManifestFromFile(mfa.Fs, manifestPath)
|
||||||
Path: manifestPath,
|
|
||||||
Fs: mfa.Fs,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load manifest: %w", err)
|
return fmt.Errorf("failed to load manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFiles := manifest.Files()
|
existingFiles := manifest.Files()
|
||||||
@@ -486,66 +80,220 @@ func (mfa *CLIApp) loadExistingEntries(
|
|||||||
// Build map of existing entries by path
|
// Build map of existing entries by path
|
||||||
existingByPath := make(map[string]*mfer.MFFilePath, len(existingFiles))
|
existingByPath := make(map[string]*mfer.MFFilePath, len(existingFiles))
|
||||||
for _, f := range existingFiles {
|
for _, f := range existingFiles {
|
||||||
existingByPath[f.GetPath()] = f
|
existingByPath[f.Path] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingByPath, nil
|
// Phase 1: Scan filesystem
|
||||||
}
|
log.Infof("scanning filesystem...")
|
||||||
|
startScan := time.Now()
|
||||||
|
|
||||||
func (mfa *CLIApp) freshenManifestOperation(
|
var entries []*freshenEntry
|
||||||
ctx context.Context, cmd *cli.Command,
|
var scanCount int64
|
||||||
) error {
|
var removed, changed, added, unchanged int64
|
||||||
log.Debug("freshenManifestOperation()")
|
|
||||||
|
|
||||||
basePath := cmd.String("base")
|
|
||||||
showProgress := cmd.Bool("progress")
|
|
||||||
|
|
||||||
// Find manifest file
|
|
||||||
manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("freshen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:contextcheck // mfer loads a manifest without a context
|
|
||||||
existingByPath, err := mfa.loadExistingEntries(manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
absBase, err := filepath.Abs(basePath)
|
absBase, err := filepath.Abs(basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("freshen: invalid base path: %w", err)
|
return fmt.Errorf("freshen: invalid base path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Scan filesystem
|
err = afero.Walk(mfa.Fs, absBase, func(path string, info fs.FileInfo, walkErr error) error {
|
||||||
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
if walkErr != nil {
|
||||||
existingByPath)
|
return walkErr
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(absBase, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("freshen: failed to compute relative path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the manifest file itself
|
||||||
|
if relPath == filepath.Base(manifestPath) || relPath == "."+filepath.Base(manifestPath) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dotfiles
|
||||||
|
if !includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle symlinks
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
if !followSymlinks {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
realPath, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip broken symlinks
|
||||||
|
}
|
||||||
|
realInfo, err := mfa.Fs.Stat(realPath)
|
||||||
|
if err != nil || realInfo.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info = realInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
scanCount++
|
||||||
|
|
||||||
|
// Check against existing manifest
|
||||||
|
existing, inManifest := existingByPath[relPath]
|
||||||
|
if inManifest {
|
||||||
|
// Check if changed (size or mtime)
|
||||||
|
existingMtime := time.Unix(existing.Mtime.Seconds, int64(existing.Mtime.Nanos))
|
||||||
|
if existing.Size != info.Size() || !existingMtime.Equal(info.ModTime()) {
|
||||||
|
changed++
|
||||||
|
log.Verbosef("M %s", relPath)
|
||||||
|
entries = append(entries, &freshenEntry{
|
||||||
|
path: relPath,
|
||||||
|
size: info.Size(),
|
||||||
|
mtime: info.ModTime(),
|
||||||
|
needsHash: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
unchanged++
|
||||||
|
entries = append(entries, &freshenEntry{
|
||||||
|
path: relPath,
|
||||||
|
size: info.Size(),
|
||||||
|
mtime: info.ModTime(),
|
||||||
|
needsHash: false,
|
||||||
|
existing: existing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Mark as seen
|
||||||
|
delete(existingByPath, relPath)
|
||||||
|
} else {
|
||||||
|
added++
|
||||||
|
log.Verbosef("A %s", relPath)
|
||||||
|
entries = append(entries, &freshenEntry{
|
||||||
|
path: relPath,
|
||||||
|
size: info.Size(),
|
||||||
|
mtime: info.ModTime(),
|
||||||
|
needsHash: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report scan progress
|
||||||
|
if showProgress && scanCount%100 == 0 {
|
||||||
|
log.Progressf("Scanning: %d files found", scanCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if showProgress {
|
||||||
|
log.ProgressDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scan filesystem: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining entries in existingByPath are removed files
|
||||||
|
removed = int64(len(existingByPath))
|
||||||
|
for path := range existingByPath {
|
||||||
|
log.Verbosef("D %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanDuration := time.Since(startScan)
|
||||||
|
log.Infof("scan complete in %s: %d unchanged, %d changed, %d added, %d removed",
|
||||||
|
scanDuration.Round(time.Millisecond), unchanged, changed, added, removed)
|
||||||
|
|
||||||
// Calculate total bytes to hash
|
// Calculate total bytes to hash
|
||||||
totalHashBytes, filesToHash := hashTotals(scanner.entries)
|
var totalHashBytes int64
|
||||||
|
var filesToHash int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.needsHash {
|
||||||
|
totalHashBytes += e.size
|
||||||
|
filesToHash++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: Hash changed and new files
|
// Phase 2: Hash changed and new files
|
||||||
if filesToHash > 0 {
|
if filesToHash > 0 {
|
||||||
log.Infof("hashing %d files (%s)...", filesToHash,
|
log.Infof("hashing %d files (%s)...", filesToHash, humanize.IBytes(uint64(totalHashBytes)))
|
||||||
humanize.IBytes(safeUint64(totalHashBytes)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasher := &freshenHasher{
|
startHash := time.Now()
|
||||||
fs: mfa.Fs,
|
var hashedFiles int64
|
||||||
absBase: absBase,
|
var hashedBytes int64
|
||||||
showProgress: showProgress,
|
|
||||||
totalHashBytes: totalHashBytes,
|
builder := mfer.NewBuilder()
|
||||||
filesToHash: filesToHash,
|
if ctx.Bool("include-timestamps") {
|
||||||
startHash: time.Now(),
|
builder.SetIncludeTimestamps(true)
|
||||||
builder: newFreshenBuilder(cmd),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = runFreshenHash(ctx, hasher, scanner.entries)
|
// Set up signing options if sign-key is provided
|
||||||
if err != nil {
|
if signKey := ctx.String("sign-key"); signKey != "" {
|
||||||
return err
|
builder.SetSigningOptions(&mfer.SigningOptions{
|
||||||
|
KeyID: mfer.GPGKeyID(signKey),
|
||||||
|
})
|
||||||
|
log.Infof("signing manifest with GPG key: %s", signKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.needsHash {
|
||||||
|
// Need to read and hash the file
|
||||||
|
absPath := filepath.Join(absBase, e.path)
|
||||||
|
f, err := mfa.Fs.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, bytesRead, err := hashFile(f, e.size, func(n int64) {
|
||||||
|
if showProgress {
|
||||||
|
currentBytes := hashedBytes + n
|
||||||
|
elapsed := time.Since(startHash)
|
||||||
|
var rate float64
|
||||||
|
var eta time.Duration
|
||||||
|
if elapsed > 0 && currentBytes > 0 {
|
||||||
|
rate = float64(currentBytes) / elapsed.Seconds()
|
||||||
|
remaining := totalHashBytes - currentBytes
|
||||||
|
if rate > 0 {
|
||||||
|
eta = time.Duration(float64(remaining)/rate) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if eta > 0 {
|
||||||
|
log.Progressf("Hashing: %d/%d files, %s/s, ETA %s",
|
||||||
|
hashedFiles, filesToHash, humanize.IBytes(uint64(rate)), eta.Round(time.Second))
|
||||||
|
} else {
|
||||||
|
log.Progressf("Hashing: %d/%d files, %s/s",
|
||||||
|
hashedFiles, filesToHash, humanize.IBytes(uint64(rate)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedBytes += bytesRead
|
||||||
|
hashedFiles++
|
||||||
|
|
||||||
|
// Add to builder with computed hash
|
||||||
|
if err := addFileToBuilder(builder, e.path, e.size, e.mtime, hash); err != nil {
|
||||||
|
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use existing entry
|
||||||
|
if err := addExistingToBuilder(builder, e.existing); err != nil {
|
||||||
|
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showProgress && filesToHash > 0 {
|
if showProgress && filesToHash > 0 {
|
||||||
@@ -554,61 +302,65 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
|
|
||||||
// Print summary
|
// Print summary
|
||||||
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
|
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
|
||||||
scanner.unchanged, scanner.changed, scanner.added, removed)
|
unchanged, changed, added, removed)
|
||||||
|
|
||||||
// Skip writing if nothing changed
|
// Skip writing if nothing changed
|
||||||
if scanner.changed == 0 && scanner.added == 0 && removed == 0 {
|
if changed == 0 && added == 0 && removed == 0 {
|
||||||
log.Infof("manifest unchanged, skipping write")
|
log.Infof("manifest unchanged, skipping write")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write updated manifest atomically (write to temp, then rename)
|
// Write updated manifest atomically (write to temp, then rename)
|
||||||
err = writeFreshenedManifest(ctx, mfa.Fs, hasher.builder, manifestPath)
|
tmpPath := manifestPath + ".tmp"
|
||||||
|
outFile, err := mfa.Fs.Create(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = builder.Build(outFile)
|
||||||
|
_ = outFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to write manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename temp to final
|
||||||
|
if err := mfa.Fs.Rename(tmpPath, manifestPath); err != nil {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to rename manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDuration := time.Since(mfa.startupTime)
|
totalDuration := time.Since(mfa.startupTime)
|
||||||
if hasher.hashedBytes > 0 {
|
if hashedBytes > 0 {
|
||||||
hashDuration := time.Since(hasher.startHash)
|
hashDuration := time.Since(startHash)
|
||||||
hashRate := float64(hasher.hashedBytes) / hashDuration.Seconds()
|
hashRate := float64(hashedBytes) / hashDuration.Seconds()
|
||||||
log.Infof("hashed %s in %.1fs (%s/s)",
|
log.Infof("hashed %s in %.1fs (%s/s)",
|
||||||
humanize.IBytes(safeUint64(hasher.hashedBytes)),
|
humanize.IBytes(uint64(hashedBytes)), totalDuration.Seconds(), humanize.IBytes(uint64(hashRate)))
|
||||||
totalDuration.Seconds(), humanize.IBytes(safeRateUint64(hashRate)))
|
|
||||||
}
|
}
|
||||||
|
log.Infof("wrote %d files to %s", len(entries), manifestPath)
|
||||||
log.Infof("wrote %d files to %s", len(scanner.entries), manifestPath)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashFile reads a file and computes its SHA256 multihash.
|
// hashFile reads a file and computes its SHA256 multihash.
|
||||||
// Progress callback is called with bytes read so far.
|
// Progress callback is called with bytes read so far.
|
||||||
func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
|
func hashFile(r io.Reader, size int64, progress func(int64)) ([]byte, int64, error) {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
buf := make([]byte, hashBufSize)
|
buf := make([]byte, 64*1024)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
for {
|
for {
|
||||||
n, err := r.Read(buf)
|
n, err := r.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
h.Write(buf[:n])
|
h.Write(buf[:n])
|
||||||
|
|
||||||
total += int64(n)
|
total += int64(n)
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress(total)
|
progress(total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returned unwrapped: the caller renders this as
|
|
||||||
// "failed to hash <path>: <err>" and adding a second layer here
|
|
||||||
// would change that message.
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, total, err
|
return nil, total, err
|
||||||
}
|
}
|
||||||
@@ -623,39 +375,15 @@ func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addFileToBuilder adds a new file entry to the builder
|
// addFileToBuilder adds a new file entry to the builder
|
||||||
func addFileToBuilder(
|
func addFileToBuilder(b *mfer.Builder, path string, size int64, mtime time.Time, hash []byte) error {
|
||||||
b *mfer.Builder, path string, size int64, mtime time.Time, mode fs.FileMode,
|
return b.AddFileWithHash(mfer.RelFilePath(path), mfer.FileSize(size), mfer.ModTime(mtime), hash)
|
||||||
hash []byte,
|
|
||||||
) error {
|
|
||||||
return b.AddFileWithHash(
|
|
||||||
mfer.RelFilePath(path), mfer.FileSize(size), mfer.ModTime(mtime), mode,
|
|
||||||
hash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// addExistingToBuilder adds an existing manifest entry to the builder.
|
// addExistingToBuilder adds an existing manifest entry to the builder
|
||||||
//
|
|
||||||
// Entries reach this path only when recordEntry classified them as
|
|
||||||
// unchanged, which requires a recorded mtime, so an absent mtime here is
|
|
||||||
// an error rather than something to paper over with the Unix epoch.
|
|
||||||
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
||||||
mtime, ok := entryMtime(entry)
|
mtime := time.Unix(entry.Mtime.Seconds, int64(entry.Mtime.Nanos))
|
||||||
if !ok {
|
if len(entry.Hashes) == 0 {
|
||||||
return fmt.Errorf("%w: %s", errEntryMissingMtime, entry.GetPath())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entry.GetHashes()) == 0 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return b.AddFileWithHash(mfer.RelFilePath(entry.Path), mfer.FileSize(entry.Size), mfer.ModTime(mtime), entry.Hashes[0].MultiHash)
|
||||||
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
|
||||||
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
|
|
||||||
fs.FileMode(entry.GetMode()), entry.GetHashes()[0].GetMultiHash())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"manifest entry %s: %w (regenerate the manifest with mfer generate)",
|
|
||||||
entry.GetPath(), err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-402
@@ -1,434 +1,82 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"bytes"
|
||||||
"maps"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/multiformats/go-multihash"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubFileInfo is a minimal fs.FileInfo for exercising recordEntry
|
|
||||||
// without touching a filesystem.
|
|
||||||
type stubFileInfo struct {
|
|
||||||
size int64
|
|
||||||
mtime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stubFileInfo) Name() string { return "stub" }
|
|
||||||
func (s stubFileInfo) Size() int64 { return s.size }
|
|
||||||
func (s stubFileInfo) Mode() os.FileMode { return 0 }
|
|
||||||
func (s stubFileInfo) ModTime() time.Time { return s.mtime }
|
|
||||||
func (s stubFileInfo) IsDir() bool { return false }
|
|
||||||
func (s stubFileInfo) Sys() any { return nil }
|
|
||||||
|
|
||||||
// setupFreshenDir writes files, by path and content, into a fresh temp
|
|
||||||
// dir and runs gen on it. It returns the temp dir and the path of the
|
|
||||||
// manifest gen wrote there.
|
|
||||||
func setupFreshenDir(
|
|
||||||
t *testing.T, fs afero.Fs, files map[string]string,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
manifestPath := filepath.Join(root, defaultManifestName)
|
|
||||||
|
|
||||||
for path, content := range files {
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Dir(filepath.Join(root, path)), 0o750))
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, path), content)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
|
|
||||||
return root, manifestPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// runFreshen runs freshen on the manifest at manifestPath for the tree
|
|
||||||
// at root and requires it to succeed.
|
|
||||||
func runFreshen(t *testing.T, fs afero.Fs, root, manifestPath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
opts := testOpts([]string{
|
|
||||||
testApp, cmdFreshen, "-q", testFlagBase, root, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
// manifestFiles returns the file entries of the manifest at path.
|
|
||||||
func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
|
||||||
Path: path,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return manifest.Files()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenUnchanged freshens a tree that has not changed since gen
|
|
||||||
// made its manifest: the manifest must list the same entries as before.
|
|
||||||
func TestFreshenUnchanged(t *testing.T) {
|
func TestFreshenUnchanged(t *testing.T) {
|
||||||
t.Parallel()
|
// Create filesystem with test files
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
root, manifestPath := setupFreshenDir(t, fs,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||||
map[string]string{testFileTxt: "content1", testDirFile: "content2"})
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||||
before := manifestFiles(t, fs, manifestPath)
|
|
||||||
|
|
||||||
runFreshen(t, fs, root, manifestPath)
|
// Generate initial manifest
|
||||||
|
opts := &mfer.ScannerOptions{Fs: fs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||||
|
|
||||||
after := manifestFiles(t, fs, manifestPath)
|
var manifestBuf bytes.Buffer
|
||||||
require.Len(t, after, len(before))
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
|
||||||
for i := range before {
|
// Write manifest to filesystem
|
||||||
assert.True(t, proto.Equal(before[i], after[i]),
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||||
"entry for %s changed", before[i].GetPath())
|
|
||||||
}
|
// Parse manifest to verify
|
||||||
|
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFreshenRecordsModeOnlyWhenAsked changes only the modes of a tree
|
|
||||||
// after gen made its manifest, which recorded every mode as 0000. freshen
|
|
||||||
// --include-permissions must record each file's permission bits, and a
|
|
||||||
// later freshen without it must record 0000 again, although no file's
|
|
||||||
// content or mtime changed.
|
|
||||||
func TestFreshenRecordsModeOnlyWhenAsked(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
root, manifestPath := setupFreshenDir(t, fs,
|
|
||||||
map[string]string{testFileTxt: "a file", testDirFile: "a file in dir"})
|
|
||||||
require.NoError(t, fs.Chmod(filepath.Join(root, testFileTxt), 0o640))
|
|
||||||
require.NoError(t, fs.Chmod(filepath.Join(root, testDirFile), 0o755))
|
|
||||||
|
|
||||||
recordedModes := func() map[string]uint32 {
|
|
||||||
modes := map[string]uint32{}
|
|
||||||
for _, f := range manifestFiles(t, fs, manifestPath) {
|
|
||||||
modes[f.GetPath()] = f.GetMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := testOpts([]string{
|
|
||||||
testApp, cmdFreshen, "-q", "--" + flagIncludePermissions,
|
|
||||||
testFlagBase, root, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
assert.Equal(t, map[string]uint32{testFileTxt: 0o640, testDirFile: 0o755},
|
|
||||||
recordedModes())
|
|
||||||
|
|
||||||
runFreshen(t, fs, root, manifestPath)
|
|
||||||
assert.Equal(t, map[string]uint32{testFileTxt: 0, testDirFile: 0},
|
|
||||||
recordedModes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertManifestLists asserts that the manifest at manifestPath lists
|
|
||||||
// exactly the files in want, each with the size and SHA-256 hash of its
|
|
||||||
// content in want and the mtime of the file of that name under root.
|
|
||||||
func assertManifestLists(
|
|
||||||
t *testing.T, fs afero.Fs, root, manifestPath string, want map[string]string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
files := manifestFiles(t, fs, manifestPath)
|
|
||||||
listed := make([]string, 0, len(files))
|
|
||||||
|
|
||||||
for _, f := range files {
|
|
||||||
listed = append(listed, f.GetPath())
|
|
||||||
|
|
||||||
content, ok := want[f.GetPath()]
|
|
||||||
if !ok {
|
|
||||||
continue // reported by the ElementsMatch below
|
|
||||||
}
|
|
||||||
|
|
||||||
digest := sha256.Sum256([]byte(content))
|
|
||||||
hash, err := multihash.Encode(digest[:], multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, int64(len(content)), f.GetSize(), f.GetPath())
|
|
||||||
require.NotEmpty(t, f.GetHashes(), f.GetPath())
|
|
||||||
assert.Equal(t, hash, f.GetHashes()[0].GetMultiHash(), f.GetPath())
|
|
||||||
|
|
||||||
info, err := fs.Stat(filepath.Join(root, f.GetPath()))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
mtime, ok := entryMtime(f)
|
|
||||||
assert.True(t, ok && mtime.Equal(info.ModTime()),
|
|
||||||
"%s: manifest has mtime %v, file has %v",
|
|
||||||
f.GetPath(), mtime, info.ModTime())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, slices.Collect(maps.Keys(want)), listed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenWithChanges makes one change to a tree after gen made its
|
|
||||||
// manifest, then freshens the manifest. The rewritten manifest must list
|
|
||||||
// exactly the files now in the tree, and check must pass on the tree.
|
|
||||||
func TestFreshenWithChanges(t *testing.T) {
|
func TestFreshenWithChanges(t *testing.T) {
|
||||||
t.Parallel()
|
// Create filesystem with test files
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
tree := map[string]string{testFileTxt: "content1", testDirFile: "content2"}
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||||
|
|
||||||
// Every file a case writes gets this mtime, which differs from the one
|
// Generate initial manifest
|
||||||
// gen recorded, so an edit that keeps the size is told apart by its
|
opts := &mfer.ScannerOptions{Fs: fs}
|
||||||
// mtime whatever the filesystem's clock resolution.
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
writtenMtime := time.Unix(1_700_000_000, 0)
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||||
|
|
||||||
for _, tc := range []struct {
|
var manifestBuf bytes.Buffer
|
||||||
name string
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
write map[string]string // files to write, by path and content
|
|
||||||
remove string // file to delete, if any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "modified file",
|
|
||||||
write: map[string]string{testDirFile: "modified content2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "modified file, same size",
|
|
||||||
write: map[string]string{testDirFile: "CONTENT2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "new file",
|
|
||||||
write: map[string]string{"dir/new.txt": "content3"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deleted file",
|
|
||||||
remove: testFileTxt,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
// Write manifest to filesystem
|
||||||
root, manifestPath := setupFreshenDir(t, fs, tree)
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||||
|
|
||||||
// want is the tree as it is after the change.
|
// Verify initial manifest has 2 files
|
||||||
want := maps.Clone(tree)
|
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||||
|
|
||||||
for path, content := range tc.write {
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, path), content)
|
|
||||||
require.NoError(t, fs.Chtimes(
|
|
||||||
filepath.Join(root, path), writtenMtime, writtenMtime))
|
|
||||||
want[path] = content
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.remove != "" {
|
|
||||||
require.NoError(t, fs.Remove(filepath.Join(root, tc.remove)))
|
|
||||||
delete(want, tc.remove)
|
|
||||||
}
|
|
||||||
|
|
||||||
runFreshen(t, fs, root, manifestPath)
|
|
||||||
|
|
||||||
assertManifestLists(t, fs, root, manifestPath, want)
|
|
||||||
|
|
||||||
opts := testOpts([]string{
|
|
||||||
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, root, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
|
|
||||||
// subdirectory of the tree it lists: the manifest is not listed, while an
|
|
||||||
// ordinary file of the same name at the top of the tree is. The manifest
|
|
||||||
// is recognized by file identity, which needs the real filesystem.
|
|
||||||
func TestFreshenLeavesManifestOutOfListing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
manifestPath := filepath.Join(root, "sub", "listing.mf")
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Join(root, "sub"), 0o750))
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, "listing.mf"), "an ordinary file")
|
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
|
|
||||||
// A new file gives freshen something to write.
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
|
||||||
|
|
||||||
opts = testOpts([]string{
|
|
||||||
testApp, "freshen", "-q", testFlagBase, root, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, []string{testFileTxt, "listing.mf", "added.txt"},
|
|
||||||
manifestPaths(t, fs, manifestPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenLeavesLeftoverTempFileOutOfListing freshens a manifest where
|
|
||||||
// an interrupted run left its temp file beside it: the leftover is not
|
|
||||||
// listed.
|
|
||||||
func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
manifestPath := filepath.Join(root, "index.mf")
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
|
|
||||||
writeTestFile(t, fs, manifestTempPath(manifestPath), "part of a manifest")
|
|
||||||
// A new file gives freshen something to write.
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
|
||||||
|
|
||||||
opts = testOpts([]string{
|
|
||||||
testApp, "freshen", "-q", testFlagBase, root, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, []string{testFileTxt, "added.txt"},
|
|
||||||
manifestPaths(t, fs, manifestPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
|
|
||||||
// with respect to MFFilePath.Mtime, which is a message pointer with
|
|
||||||
// proto3 field presence and may legitimately be absent.
|
|
||||||
//
|
|
||||||
// An absent mtime must never be read as time.Unix(0, 0): that value
|
|
||||||
// never equals a real modification time, so every entry would be
|
|
||||||
// classified as changed, re-hashed, and the manifest rewritten
|
|
||||||
// unconditionally - the exact inverse of what freshen is for, and
|
|
||||||
// silent. An entry with no mtime is therefore "changed" because it
|
|
||||||
// cannot be compared, not because it looks like it dates from 1970.
|
|
||||||
func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const relPath = "file1.txt"
|
|
||||||
|
|
||||||
// The scanned file's mtime is the Unix epoch. If recordEntry ever misreads
|
|
||||||
// an absent manifest mtime as the epoch, the "absent" case below would
|
|
||||||
// compare equal to this and be classified unchanged, so the test fails.
|
|
||||||
mtime := time.Unix(0, 0)
|
|
||||||
info := stubFileInfo{size: 8, mtime: mtime}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
entry *mfer.MFFilePath
|
|
||||||
needsHash bool
|
|
||||||
changed int64
|
|
||||||
unchanged int64
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "matching mtime and size is unchanged",
|
|
||||||
entry: &mfer.MFFilePath{
|
|
||||||
Path: relPath,
|
|
||||||
Size: 8,
|
|
||||||
Mtime: &mfer.Timestamp{Seconds: mtime.Unix()},
|
|
||||||
},
|
|
||||||
needsHash: false,
|
|
||||||
changed: 0,
|
|
||||||
unchanged: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "absent mtime is changed, not epoch",
|
|
||||||
entry: &mfer.MFFilePath{
|
|
||||||
Path: relPath,
|
|
||||||
Size: 8,
|
|
||||||
Mtime: nil,
|
|
||||||
},
|
|
||||||
needsHash: true,
|
|
||||||
changed: 1,
|
|
||||||
unchanged: 0,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := &freshenScanner{
|
|
||||||
existingByPath: map[string]*mfer.MFFilePath{relPath: tc.entry},
|
|
||||||
}
|
|
||||||
s.recordEntry(relPath, info)
|
|
||||||
|
|
||||||
require.Len(t, s.entries, 1)
|
|
||||||
assert.Equal(t, tc.needsHash, s.entries[0].needsHash)
|
|
||||||
assert.Equal(t, tc.changed, s.changed)
|
|
||||||
assert.Equal(t, tc.unchanged, s.unchanged)
|
|
||||||
assert.Zero(t, s.added)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenAddExistingRejectsMissingMtime pins that an entry with no
|
|
||||||
// mtime is never carried forward into a rebuilt manifest with a
|
|
||||||
// fabricated epoch timestamp.
|
|
||||||
func TestFreshenAddExistingRejectsMissingMtime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 2)
|
||||||
|
|
||||||
b := mfer.NewBuilder()
|
// Add a new file
|
||||||
entry := &mfer.MFFilePath{
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file3.txt", []byte("content3"), 0o644))
|
||||||
Path: "file1.txt",
|
|
||||||
Size: 8,
|
|
||||||
Mtime: nil,
|
|
||||||
Hashes: []*mfer.MFFileChecksum{
|
|
||||||
{MultiHash: hash},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = addExistingToBuilder(b, entry)
|
// Modify file2 (change content and size)
|
||||||
require.ErrorIs(t, err, errEntryMissingMtime)
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("modified content2"), 0o644))
|
||||||
assert.Contains(t, err.Error(), "file1.txt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenAddExistingRejectsShortHash pins that an existing manifest
|
// Remove file1
|
||||||
// entry whose hash the builder refuses is reported as a problem with the
|
require.NoError(t, fs.Remove("/testdir/file1.txt"))
|
||||||
// manifest, with the command that fixes it.
|
|
||||||
func TestFreshenAddExistingRejectsShortHash(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
// Note: The freshen operation would need to be run here
|
||||||
require.NoError(t, err)
|
// For now, we just verify the test setup is correct
|
||||||
|
exists, _ := afero.Exists(fs, "/testdir/file1.txt")
|
||||||
|
assert.False(t, exists)
|
||||||
|
|
||||||
b := mfer.NewBuilder()
|
exists, _ = afero.Exists(fs, "/testdir/file3.txt")
|
||||||
entry := &mfer.MFFilePath{
|
assert.True(t, exists)
|
||||||
Path: "old.txt",
|
|
||||||
Size: 8,
|
|
||||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
|
||||||
Hashes: []*mfer.MFFileChecksum{
|
|
||||||
{MultiHash: sha1Hash},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = addExistingToBuilder(b, entry)
|
content, _ := afero.ReadFile(fs, "/testdir/file2.txt")
|
||||||
require.Error(t, err)
|
assert.Equal(t, "modified content2", string(content))
|
||||||
assert.Contains(t, err.Error(), "manifest entry old.txt")
|
|
||||||
assert.Contains(t, err.Error(), "mfer generate")
|
|
||||||
assert.Zero(t, b.FileCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntryMtime pins the presence semantics the callers depend on.
|
|
||||||
func TestEntryMtime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got, ok := entryMtime(&mfer.MFFilePath{Mtime: nil})
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.True(t, got.IsZero())
|
|
||||||
|
|
||||||
got, ok = entryMtime(&mfer.MFFilePath{
|
|
||||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000, Nanos: 500},
|
|
||||||
})
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, time.Unix(1_700_000_000, 500), got)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-209
@@ -1,8 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -13,273 +11,165 @@ import (
|
|||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||||
// errPathNotExist indicates an input path that does not exist.
|
log.Debug("generateManifestOperation()")
|
||||||
errPathNotExist = errors.New("path does not exist")
|
|
||||||
// errOutputExists indicates the output file already exists and
|
|
||||||
// --force was not given. It is wrapped mid-sentence so that the
|
|
||||||
// rendered message stays exactly as mfer has always printed it.
|
|
||||||
errOutputExists = errors.New(
|
|
||||||
"already exists (use --force to overwrite)")
|
|
||||||
)
|
|
||||||
|
|
||||||
// reportEnumProgress renders enumeration progress until the channel
|
|
||||||
// closes.
|
|
||||||
func reportEnumProgress(progress <-chan mfer.EnumerateStatus, wg *sync.WaitGroup) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for status := range progress {
|
|
||||||
log.Progressf("Enumerating: %d files, %s",
|
|
||||||
status.FilesFound,
|
|
||||||
humanize.IBytes(safeUint64(int64(status.BytesFound))))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.ProgressDone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportScanProgress renders scan progress until the channel closes.
|
|
||||||
func reportScanProgress(progress <-chan mfer.ScanStatus, wg *sync.WaitGroup) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for status := range progress {
|
|
||||||
if status.ETA > 0 {
|
|
||||||
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
|
||||||
status.ScannedFiles,
|
|
||||||
status.TotalFiles,
|
|
||||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
|
||||||
status.ETA.Round(time.Second))
|
|
||||||
} else {
|
|
||||||
log.Progressf("Scanning: %d/%d files, %s/s",
|
|
||||||
status.ScannedFiles,
|
|
||||||
status.TotalFiles,
|
|
||||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.ProgressDone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectInputPaths validates the input path arguments and returns them
|
|
||||||
// as absolute paths.
|
|
||||||
func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
|
||||||
paths := make([]string, 0, args.Len())
|
|
||||||
|
|
||||||
for i := range args.Len() {
|
|
||||||
inputPath := args.Get(i)
|
|
||||||
|
|
||||||
ap, err := filepath.Abs(inputPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
|
||||||
}
|
|
||||||
// Validate path exists before adding to list
|
|
||||||
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
|
||||||
return nil, fmt.Errorf("%w: %s", errPathNotExist, inputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("enumerating path: %s", ap)
|
|
||||||
paths = append(paths, ap)
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildScannerOptions constructs scanner options from the CLI flags.
|
|
||||||
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
|
|
||||||
output := cmd.String("output")
|
|
||||||
opts := &mfer.ScannerOptions{
|
opts := &mfer.ScannerOptions{
|
||||||
IncludeDotfiles: cmd.Bool("include-dotfiles"),
|
IncludeDotfiles: ctx.Bool("include-dotfiles"),
|
||||||
FollowSymLinks: cmd.Bool("follow-symlinks"),
|
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
||||||
IncludeTimestamps: cmd.Bool("include-timestamps"),
|
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
||||||
IncludePermissions: cmd.Bool(flagIncludePermissions),
|
Fs: mfa.Fs,
|
||||||
Fs: mfa.Fs,
|
|
||||||
// Neither a manifest being replaced nor a temp file left by an
|
|
||||||
// interrupted run belongs in the new manifest.
|
|
||||||
ExcludePaths: []string{output, manifestTempPath(output)},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set seed for deterministic UUID if provided
|
// Set seed for deterministic UUID if provided
|
||||||
if seed := cmd.String("seed"); seed != "" {
|
if seed := ctx.String("seed"); seed != "" {
|
||||||
opts.Seed = seed
|
opts.Seed = seed
|
||||||
|
|
||||||
log.Infof("using deterministic seed for manifest UUID")
|
log.Infof("using deterministic seed for manifest UUID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up signing options if sign-key is provided
|
// Set up signing options if sign-key is provided
|
||||||
if signKey := cmd.String("sign-key"); signKey != "" {
|
if signKey := ctx.String("sign-key"); signKey != "" {
|
||||||
opts.SigningOptions = &mfer.SigningOptions{
|
opts.SigningOptions = &mfer.SigningOptions{
|
||||||
KeyID: mfer.GPGKeyID(signKey),
|
KeyID: mfer.GPGKeyID(signKey),
|
||||||
}
|
}
|
||||||
log.Infof("signing manifest with GPG key: %s", signKey)
|
log.Infof("signing manifest with GPG key: %s", signKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
}
|
|
||||||
|
|
||||||
// enumerateInputs runs the enumeration phase over the argument paths,
|
|
||||||
// or the current directory when no arguments are given.
|
|
||||||
func (mfa *CLIApp) enumerateInputs(
|
|
||||||
s *mfer.Scanner, args cli.Args, enumProgress chan mfer.EnumerateStatus,
|
|
||||||
) error {
|
|
||||||
if args.Len() == 0 {
|
|
||||||
// Default to current directory
|
|
||||||
err := s.EnumeratePath(".", enumProgress)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"generate: failed to enumerate current directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect and validate all paths first
|
|
||||||
paths, err := mfa.collectInputPaths(args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.EnumeratePaths(enumProgress, paths...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupOnSignal installs a handler that removes the temp output file
|
|
||||||
// and exits when the process is interrupted. It returns the signal
|
|
||||||
// channel so the caller can stop and close it when done.
|
|
||||||
func (mfa *CLIApp) cleanupOnSignal(outFile afero.File, tmpPath string) chan os.Signal {
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
|
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
sig, ok := <-sigChan
|
|
||||||
if !ok || sig == nil {
|
|
||||||
return // Channel closed normally, not a signal
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = outFile.Close()
|
|
||||||
_ = mfa.Fs.Remove(tmpPath)
|
|
||||||
|
|
||||||
os.Exit(1)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return sigChan
|
|
||||||
}
|
|
||||||
|
|
||||||
// runEnumeratePhase enumerates all input paths with optional progress
|
|
||||||
// reporting and logs the totals.
|
|
||||||
func (mfa *CLIApp) runEnumeratePhase(cmd *cli.Command, s *mfer.Scanner) error {
|
|
||||||
// Set up enumeration progress reporting
|
|
||||||
var (
|
|
||||||
enumProgress chan mfer.EnumerateStatus
|
|
||||||
enumWg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
if cmd.Bool("progress") {
|
|
||||||
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
|
||||||
|
|
||||||
enumWg.Add(1)
|
|
||||||
|
|
||||||
go reportEnumProgress(enumProgress, &enumWg)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := mfa.enumerateInputs(s, cmd.Args(), enumProgress)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
enumWg.Wait()
|
|
||||||
|
|
||||||
log.Infof("enumerated %d files, %s total", s.FileCount(),
|
|
||||||
humanize.IBytes(safeUint64(int64(s.TotalBytes()))))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) generateManifestOperation(
|
|
||||||
ctx context.Context, cmd *cli.Command,
|
|
||||||
) error {
|
|
||||||
log.Debug("generateManifestOperation()")
|
|
||||||
|
|
||||||
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
|
|
||||||
|
|
||||||
// Phase 1: Enumeration - collect paths and stat files
|
// Phase 1: Enumeration - collect paths and stat files
|
||||||
err := mfa.runEnumeratePhase(cmd, s)
|
args := ctx.Args()
|
||||||
if err != nil {
|
showProgress := ctx.Bool("progress")
|
||||||
return err
|
|
||||||
|
// Set up enumeration progress reporting
|
||||||
|
var enumProgress chan mfer.EnumerateStatus
|
||||||
|
var enumWg sync.WaitGroup
|
||||||
|
if showProgress {
|
||||||
|
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
||||||
|
enumWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer enumWg.Done()
|
||||||
|
for status := range enumProgress {
|
||||||
|
log.Progressf("Enumerating: %d files, %s",
|
||||||
|
status.FilesFound,
|
||||||
|
humanize.IBytes(uint64(status.BytesFound)))
|
||||||
|
}
|
||||||
|
log.ProgressDone()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
showProgress := cmd.Bool("progress")
|
if args.Len() == 0 {
|
||||||
|
// Default to current directory
|
||||||
|
if err := s.EnumeratePath(".", enumProgress); err != nil {
|
||||||
|
return fmt.Errorf("generate: failed to enumerate current directory: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Collect and validate all paths first
|
||||||
|
paths := make([]string, 0, args.Len())
|
||||||
|
for i := 0; i < args.Len(); i++ {
|
||||||
|
inputPath := args.Get(i)
|
||||||
|
ap, err := filepath.Abs(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
||||||
|
}
|
||||||
|
// Validate path exists before adding to list
|
||||||
|
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
||||||
|
return fmt.Errorf("path does not exist: %s", inputPath)
|
||||||
|
}
|
||||||
|
log.Debugf("enumerating path: %s", ap)
|
||||||
|
paths = append(paths, ap)
|
||||||
|
}
|
||||||
|
if err := s.EnumeratePaths(enumProgress, paths...); err != nil {
|
||||||
|
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enumWg.Wait()
|
||||||
|
|
||||||
|
log.Infof("enumerated %d files, %s total", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())))
|
||||||
|
|
||||||
// Check if output file exists
|
// Check if output file exists
|
||||||
outputPath := cmd.String("output")
|
outputPath := ctx.String("output")
|
||||||
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
|
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists {
|
||||||
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
if !ctx.Bool("force") {
|
||||||
|
return fmt.Errorf("output file %s already exists (use --force to overwrite)", outputPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temp file for atomic write
|
// Create temp file for atomic write
|
||||||
tmpPath := manifestTempPath(outputPath)
|
tmpPath := outputPath + ".tmp"
|
||||||
|
|
||||||
outFile, err := mfa.Fs.Create(tmpPath)
|
outFile, err := mfa.Fs.Create(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up signal handler to clean up temp file on Ctrl-C
|
// Set up signal handler to clean up temp file on Ctrl-C
|
||||||
sigChan := mfa.cleanupOnSignal(outFile, tmpPath)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig, ok := <-sigChan
|
||||||
|
if !ok || sig == nil {
|
||||||
|
return // Channel closed normally, not a signal
|
||||||
|
}
|
||||||
|
_ = outFile.Close()
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
os.Exit(1)
|
||||||
|
}()
|
||||||
|
|
||||||
// Clean up temp file on any error or interruption
|
// Clean up temp file on any error or interruption
|
||||||
success := false
|
success := false
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
signal.Stop(sigChan)
|
signal.Stop(sigChan)
|
||||||
close(sigChan)
|
close(sigChan)
|
||||||
|
|
||||||
_ = outFile.Close()
|
_ = outFile.Close()
|
||||||
|
|
||||||
if !success {
|
if !success {
|
||||||
_ = mfa.Fs.Remove(tmpPath)
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Phase 2: Scan - read file contents and generate manifest
|
// Phase 2: Scan - read file contents and generate manifest
|
||||||
var (
|
var scanProgress chan mfer.ScanStatus
|
||||||
scanProgress chan mfer.ScanStatus
|
var scanWg sync.WaitGroup
|
||||||
scanWg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
if showProgress {
|
if showProgress {
|
||||||
scanProgress = make(chan mfer.ScanStatus, 1)
|
scanProgress = make(chan mfer.ScanStatus, 1)
|
||||||
|
|
||||||
scanWg.Add(1)
|
scanWg.Add(1)
|
||||||
|
go func() {
|
||||||
go reportScanProgress(scanProgress, &scanWg)
|
defer scanWg.Done()
|
||||||
|
for status := range scanProgress {
|
||||||
|
if status.ETA > 0 {
|
||||||
|
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
||||||
|
status.ScannedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||||
|
status.ETA.Round(time.Second))
|
||||||
|
} else {
|
||||||
|
log.Progressf("Scanning: %d/%d files, %s/s",
|
||||||
|
status.ScannedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ProgressDone()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.ToManifest(ctx, outFile, scanProgress)
|
err = s.ToManifest(ctx.Context, outFile, scanProgress)
|
||||||
|
|
||||||
scanWg.Wait()
|
scanWg.Wait()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate manifest: %w", err)
|
return fmt.Errorf("failed to generate manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close file before rename to ensure all data is flushed
|
// Close file before rename to ensure all data is flushed
|
||||||
err = outFile.Close()
|
if err := outFile.Close(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
err = mfa.Fs.Rename(tmpPath, outputPath)
|
if err := mfa.Fs.Rename(tmpPath, outputPath); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,9 +177,7 @@ func (mfa *CLIApp) generateManifestOperation(
|
|||||||
|
|
||||||
elapsed := time.Since(mfa.startupTime).Seconds()
|
elapsed := time.Since(mfa.startupTime).Seconds()
|
||||||
rate := float64(s.TotalBytes()) / elapsed
|
rate := float64(s.TotalBytes()) / elapsed
|
||||||
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(),
|
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())), outputPath, elapsed, humanize.IBytes(uint64(rate)))
|
||||||
humanize.IBytes(safeUint64(int64(s.TotalBytes()))), outputPath, elapsed,
|
|
||||||
humanize.IBytes(safeRateUint64(rate)))
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-19
@@ -1,35 +1,32 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (mfa *CLIApp) listManifestOperation(ctx context.Context, cmd *cli.Command) error {
|
func (mfa *CLIApp) listManifestOperation(ctx *cli.Context) error {
|
||||||
// Default to ErrorLevel for clean output
|
// Default to ErrorLevel for clean output
|
||||||
log.SetLevel(log.ErrorLevel)
|
log.SetLevel(log.ErrorLevel)
|
||||||
|
|
||||||
longFormat := cmd.Bool("long")
|
longFormat := ctx.Bool("long")
|
||||||
print0 := cmd.Bool("print0")
|
print0 := ctx.Bool("print0")
|
||||||
|
|
||||||
pathOrURL, err := mfa.resolveManifestArg(cmd)
|
pathOrURL, err := mfa.resolveManifestArg(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list: %w", err)
|
return fmt.Errorf("list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
rc, err := mfa.openManifestReader(pathOrURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list: %w", err)
|
return fmt.Errorf("list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rc.Close() }()
|
defer func() { _ = rc.Close() }()
|
||||||
|
|
||||||
//nolint:contextcheck // mfer loads a manifest without a context
|
|
||||||
manifest, err := mfer.NewManifestFromReader(rc)
|
manifest, err := mfer.NewManifestFromReader(rc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list: failed to parse manifest: %w", err)
|
return fmt.Errorf("list: failed to parse manifest: %w", err)
|
||||||
@@ -45,17 +42,10 @@ func (mfa *CLIApp) listManifestOperation(ctx context.Context, cmd *cli.Command)
|
|||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if longFormat {
|
if longFormat {
|
||||||
// An entry may legitimately carry no mtime; render that as
|
mtime := time.Unix(f.Mtime.Seconds, int64(f.Mtime.Nanos))
|
||||||
// mtimeAbsent rather than as the Unix epoch.
|
_, _ = fmt.Fprintf(mfa.Stdout, "%d\t%s\t%s%s", f.Size, mtime.Format(time.RFC3339), f.Path, lineEnd)
|
||||||
mtimeStr := mtimeAbsent
|
|
||||||
if mtime, ok := entryMtime(f); ok {
|
|
||||||
mtimeStr = mtime.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(mfa.Stdout, "%04o\t%d\t%s\t%s%s",
|
|
||||||
f.GetMode(), f.GetSize(), mtimeStr, f.GetPath(), lineEnd)
|
|
||||||
} else {
|
} else {
|
||||||
_, _ = fmt.Fprintf(mfa.Stdout, "%s%s", f.GetPath(), lineEnd)
|
_, _ = fmt.Fprintf(mfa.Stdout, "%s%s", f.Path, lineEnd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// manifestFetchTimeout bounds HTTP requests made to fetch a manifest.
|
|
||||||
const manifestFetchTimeout = 30 * time.Second
|
|
||||||
|
|
||||||
// errHTTPStatus indicates an HTTP response with a non-OK status code.
|
|
||||||
//
|
|
||||||
// Its text is the literal "HTTP" prefix of the rendered "HTTP <code>"
|
|
||||||
// message that mfer has always printed, so that wrapping it does not
|
|
||||||
// change any user-visible output. Match it with errors.Is; do not read
|
|
||||||
// its message.
|
|
||||||
var errHTTPStatus = errors.New("HTTP")
|
|
||||||
|
|
||||||
// isHTTPURL returns true if the string starts with http:// or https://.
|
// isHTTPURL returns true if the string starts with http:// or https://.
|
||||||
func isHTTPURL(s string) bool {
|
func isHTTPURL(s string) bool {
|
||||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
@@ -30,57 +17,40 @@ func isHTTPURL(s string) bool {
|
|||||||
|
|
||||||
// openManifestReader opens a manifest from a path or URL and returns a ReadCloser.
|
// openManifestReader opens a manifest from a path or URL and returns a ReadCloser.
|
||||||
// The caller must close the returned reader.
|
// The caller must close the returned reader.
|
||||||
func (mfa *CLIApp) openManifestReader(
|
func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
|
||||||
ctx context.Context, pathOrURL string,
|
|
||||||
) (io.ReadCloser, error) {
|
|
||||||
if isHTTPURL(pathOrURL) {
|
if isHTTPURL(pathOrURL) {
|
||||||
client := &http.Client{Timeout: manifestFetchTimeout}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(pathOrURL) //nolint:gosec // user-provided URL is intentional
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pathOrURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("failed to fetch %s: HTTP %d", pathOrURL, resp.StatusCode)
|
||||||
return nil, fmt.Errorf("failed to fetch %s: %w %d",
|
|
||||||
pathOrURL, errHTTPStatus, resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := mfa.Fs.Open(pathOrURL)
|
f, err := mfa.Fs.Open(pathOrURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveManifestArg resolves the manifest path from CLI arguments.
|
// resolveManifestArg resolves the manifest path from CLI arguments.
|
||||||
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf.
|
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf/.index.mf.
|
||||||
// If no argument is given, the current directory is searched.
|
// If no argument is given, the current directory is searched.
|
||||||
func (mfa *CLIApp) resolveManifestArg(cmd *cli.Command) (string, error) {
|
func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
|
||||||
if cmd.Args().Len() > 0 {
|
if ctx.Args().Len() > 0 {
|
||||||
arg := cmd.Args().Get(0)
|
arg := ctx.Args().Get(0)
|
||||||
if isHTTPURL(arg) {
|
if isHTTPURL(arg) {
|
||||||
return arg, nil
|
return arg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
info, statErr := mfa.Fs.Stat(arg)
|
info, statErr := mfa.Fs.Stat(arg)
|
||||||
if statErr == nil && info.IsDir() {
|
if statErr == nil && info.IsDir() {
|
||||||
return findManifest(mfa.Fs, arg)
|
return findManifest(mfa.Fs, arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return arg, nil
|
return arg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return findManifest(mfa.Fs, ".")
|
return findManifest(mfa.Fs, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-381
@@ -1,63 +1,26 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v2"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command and flag names shared across command definitions and tests.
|
|
||||||
const (
|
|
||||||
cmdGenerate = "generate"
|
|
||||||
cmdCheck = "check"
|
|
||||||
cmdFreshen = "freshen"
|
|
||||||
cmdExport = "export"
|
|
||||||
cmdFetch = "fetch"
|
|
||||||
cmdList = "list"
|
|
||||||
cmdVersion = "version"
|
|
||||||
|
|
||||||
flagProgress = "progress"
|
|
||||||
flagTimeout = "timeout"
|
|
||||||
flagDest = "dest"
|
|
||||||
flagRequireSignature = "require-signature"
|
|
||||||
flagIncludePermissions = "include-permissions"
|
|
||||||
|
|
||||||
manifestArgsUsage = "[manifest file]"
|
|
||||||
|
|
||||||
// defaultManifestName is the filename gen writes by default, the one
|
|
||||||
// looked for when a command is given a directory, and the one fetch
|
|
||||||
// appends to a directory URL.
|
|
||||||
defaultManifestName = "index.mf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// manifestTempPath returns the temp file gen and freshen write a manifest
|
|
||||||
// to before renaming it to out.
|
|
||||||
func manifestTempPath(out string) string {
|
|
||||||
return out + ".tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
// errUnknownCommand indicates an unrecognized command argument.
|
|
||||||
var errUnknownCommand = errors.New("unknown command")
|
|
||||||
|
|
||||||
// CLIApp is the main CLI application container. It holds configuration,
|
// CLIApp is the main CLI application container. It holds configuration,
|
||||||
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
||||||
//
|
|
||||||
//nolint:revive // established name used throughout the codebase and tests
|
|
||||||
type CLIApp struct {
|
type CLIApp struct {
|
||||||
appname string
|
appname string
|
||||||
version string
|
version string
|
||||||
gitrev string
|
gitrev string
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
exitCode int
|
||||||
app *cli.Command
|
app *cli.App
|
||||||
|
|
||||||
Stdin io.Reader // Standard input stream
|
Stdin io.Reader // Standard input stream
|
||||||
Stdout io.Writer // Standard output stream for normal output
|
Stdout io.Writer // Standard output stream for normal output
|
||||||
@@ -78,336 +41,54 @@ const banner = `
|
|||||||
\ \:\ \ \:\ \ \::/ \ \:\
|
\ \:\ \ \:\ \ \::/ \ \:\
|
||||||
\__\/ \__\/ \__\/ \__\/`
|
\__\/ \__\/ \__\/ \__\/`
|
||||||
|
|
||||||
|
func (mfa *CLIApp) printBanner() {
|
||||||
|
if log.GetLevel() <= log.InfoLevel {
|
||||||
|
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
||||||
|
_, _ = fmt.Fprintf(mfa.Stdout, " mfer by @sneak: v%s released %s\n", mfer.Version, mfer.ReleaseDate)
|
||||||
|
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VersionString returns the version and git revision formatted for display.
|
// VersionString returns the version and git revision formatted for display.
|
||||||
func (mfa *CLIApp) VersionString() string {
|
func (mfa *CLIApp) VersionString() string {
|
||||||
if mfa.gitrev != "" {
|
if mfa.gitrev != "" {
|
||||||
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
||||||
}
|
}
|
||||||
|
|
||||||
return mfer.Version
|
return mfer.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// printVersion writes the version line shared by the --version flag and the
|
func (mfa *CLIApp) setVerbosity(c *cli.Context) {
|
||||||
// version subcommand, so both produce identical output.
|
|
||||||
func (mfa *CLIApp) printVersion() {
|
|
||||||
_, _ = fmt.Fprintf(mfa.Stdout, "%s version %s\n", mfa.appname, mfa.VersionString())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) printBanner() {
|
|
||||||
if log.GetLevel() <= log.InfoLevel {
|
|
||||||
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
|
||||||
_, _ = fmt.Fprintf(mfa.Stdout,
|
|
||||||
" mfer by @sneak: v%s released %s\n",
|
|
||||||
mfer.Version, mfer.ReleaseDate)
|
|
||||||
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setVerbosity sets the log level from -v and -q, given before the subcommand
|
|
||||||
// name (the root's copies), after it (the subcommand's own copies), or both.
|
|
||||||
// urfave/cli reads a flag from the nearest command that defines it, so each
|
|
||||||
// command in the lineage is asked. The highest -v count wins rather than the
|
|
||||||
// sum, because a subcommand without its own copies reads the root's.
|
|
||||||
func (mfa *CLIApp) setVerbosity(cmd *cli.Command) {
|
|
||||||
_, present := os.LookupEnv("MFER_DEBUG")
|
_, present := os.LookupEnv("MFER_DEBUG")
|
||||||
|
if present {
|
||||||
verbosity := 0
|
|
||||||
quiet := false
|
|
||||||
|
|
||||||
for _, c := range cmd.Lineage() {
|
|
||||||
verbosity = max(verbosity, c.Count("verbose"))
|
|
||||||
quiet = quiet || c.Bool("quiet")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case present:
|
|
||||||
log.EnableDebugLogging()
|
log.EnableDebugLogging()
|
||||||
case quiet:
|
} else if c.Bool("quiet") {
|
||||||
log.SetLevel(log.ErrorLevel)
|
log.SetLevel(log.ErrorLevel)
|
||||||
default:
|
} else {
|
||||||
log.SetLevelFromVerbosity(verbosity)
|
log.SetLevelFromVerbosity(c.Count("verbose"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// commonFlags returns the -v and -q flags taken by the root and by the
|
// commonFlags returns the flags shared by most commands (-v, -q)
|
||||||
// generate, check, freshen and fetch subcommands. They are local, so the
|
|
||||||
// root's copies are not inherited by the subcommands that do not take them.
|
|
||||||
func commonFlags() []cli.Flag {
|
func commonFlags() []cli.Flag {
|
||||||
return []cli.Flag{
|
return []cli.Flag{
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "verbose",
|
Name: "verbose",
|
||||||
Aliases: []string{"v"},
|
Aliases: []string{"v"},
|
||||||
Usage: "Increase verbosity (-v for verbose, -v -v for debug)",
|
Usage: "Increase verbosity (-v for verbose, -vv for debug)",
|
||||||
Local: true,
|
Count: new(int),
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "quiet",
|
Name: "quiet",
|
||||||
Aliases: []string{"q"},
|
Aliases: []string{"q"},
|
||||||
Usage: "Suppress output except errors",
|
Usage: "Suppress output except errors",
|
||||||
Local: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopOnFirstArg returns the StopOnNthArg setting every command uses: flags
|
|
||||||
// are read only before the command's first argument, and everything after it
|
|
||||||
// is an argument, as with urfave/cli v2. So `mfer gen d -v` names a path "-v".
|
|
||||||
func stopOnFirstArg() *int {
|
|
||||||
n := 1
|
|
||||||
|
|
||||||
return &n
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireSignatureFlag returns the --require-signature flag taken by the
|
|
||||||
// check and fetch subcommands.
|
|
||||||
func requireSignatureFlag() *cli.StringFlag {
|
|
||||||
return &cli.StringFlag{
|
|
||||||
Name: flagRequireSignature,
|
|
||||||
Aliases: []string{"S"},
|
|
||||||
Usage: "Require manifest to be signed by the specified GPG key ID",
|
|
||||||
Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// includePermissionsFlag returns the --include-permissions flag taken by the
|
|
||||||
// generate and freshen subcommands.
|
|
||||||
func includePermissionsFlag() *cli.BoolFlag {
|
|
||||||
return &cli.BoolFlag{
|
|
||||||
Name: flagIncludePermissions,
|
|
||||||
Usage: "Record each file's permission bits in manifest " +
|
|
||||||
"(recorded as 0000 by default)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) generateCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdGenerate,
|
|
||||||
Aliases: []string{"gen"},
|
|
||||||
Usage: "Generate manifest file",
|
|
||||||
ArgsUsage: "[path ...]",
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
||||||
mfa.setVerbosity(cmd)
|
|
||||||
mfa.printBanner()
|
|
||||||
|
|
||||||
return mfa.generateManifestOperation(ctx, cmd)
|
|
||||||
},
|
|
||||||
Flags: append(commonFlags(),
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "follow-symlinks",
|
|
||||||
Aliases: []string{"L"},
|
|
||||||
Usage: "Resolve encountered symlinks",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "include-dotfiles",
|
|
||||||
Aliases: []string{"IncludeDotfiles"},
|
|
||||||
|
|
||||||
Usage: "Include dot (hidden) files (excluded by default)",
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "output",
|
|
||||||
Value: defaultManifestName,
|
|
||||||
Aliases: []string{"o"},
|
|
||||||
Usage: "Specify output filename",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "force",
|
|
||||||
Aliases: []string{"f"},
|
|
||||||
Usage: "Overwrite output file if it exists",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: flagProgress,
|
|
||||||
Aliases: []string{"P"},
|
|
||||||
Usage: "Show progress during enumeration and scanning",
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "sign-key",
|
|
||||||
Aliases: []string{"s"},
|
|
||||||
Usage: "GPG key ID to sign the manifest with",
|
|
||||||
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "seed",
|
|
||||||
Usage: "Seed value for deterministic manifest UUID",
|
|
||||||
Sources: cli.EnvVars("MFER_SEED"),
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "include-timestamps",
|
|
||||||
Usage: "Include createdAt timestamp in manifest " +
|
|
||||||
"(omitted by default for determinism)",
|
|
||||||
},
|
|
||||||
includePermissionsFlag(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) checkCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdCheck,
|
|
||||||
Usage: "Validate files using manifest file",
|
|
||||||
ArgsUsage: manifestArgsUsage,
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
||||||
mfa.setVerbosity(cmd)
|
|
||||||
mfa.printBanner()
|
|
||||||
|
|
||||||
return mfa.checkManifestOperation(ctx, cmd)
|
|
||||||
},
|
|
||||||
Flags: append(commonFlags(),
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "base",
|
|
||||||
Aliases: []string{"b"},
|
|
||||||
Value: ".",
|
|
||||||
Usage: "Base directory for resolving relative paths from manifest",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: flagProgress,
|
|
||||||
Aliases: []string{"P"},
|
|
||||||
Usage: "Show progress during checking",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "no-extra-files",
|
|
||||||
Usage: "Fail, instead of warning, if files in base directory are not in manifest",
|
|
||||||
},
|
|
||||||
requireSignatureFlag(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) freshenCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdFreshen,
|
|
||||||
Usage: "Update manifest with changed, new, and removed files",
|
|
||||||
ArgsUsage: manifestArgsUsage,
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
||||||
mfa.setVerbosity(cmd)
|
|
||||||
mfa.printBanner()
|
|
||||||
|
|
||||||
return mfa.freshenManifestOperation(ctx, cmd)
|
|
||||||
},
|
|
||||||
Flags: append(commonFlags(),
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "base",
|
|
||||||
Aliases: []string{"b"},
|
|
||||||
Value: ".",
|
|
||||||
Usage: "Base directory for resolving relative paths",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "follow-symlinks",
|
|
||||||
Aliases: []string{"L"},
|
|
||||||
Usage: "Resolve encountered symlinks",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "include-dotfiles",
|
|
||||||
Aliases: []string{"IncludeDotfiles"},
|
|
||||||
|
|
||||||
Usage: "Include dot (hidden) files (excluded by default)",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: flagProgress,
|
|
||||||
Aliases: []string{"P"},
|
|
||||||
Usage: "Show progress during scanning and hashing",
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "sign-key",
|
|
||||||
Aliases: []string{"s"},
|
|
||||||
Usage: "GPG key ID to sign the manifest with",
|
|
||||||
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "include-timestamps",
|
|
||||||
Usage: "Include createdAt timestamp in manifest " +
|
|
||||||
"(omitted by default for determinism)",
|
|
||||||
},
|
|
||||||
includePermissionsFlag(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) exportCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdExport,
|
|
||||||
Usage: "Export manifest contents as JSON",
|
|
||||||
ArgsUsage: "[manifest file or URL]",
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
||||||
mfa.setVerbosity(cmd)
|
|
||||||
|
|
||||||
return mfa.exportManifestOperation(ctx, cmd)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) versionCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdVersion,
|
|
||||||
Usage: "Show version",
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(context.Context, *cli.Command) error {
|
|
||||||
mfa.printVersion()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) listCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdList,
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Usage: "List files in manifest",
|
|
||||||
ArgsUsage: manifestArgsUsage,
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: mfa.listManifestOperation,
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "long",
|
|
||||||
Aliases: []string{"l"},
|
|
||||||
Usage: "Show mode, size and mtime",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "print0",
|
|
||||||
Usage: "Separate entries with NUL character (for xargs -0)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
|
||||||
return &cli.Command{
|
|
||||||
Name: cmdFetch,
|
|
||||||
Usage: "fetch manifest and referenced files",
|
|
||||||
ArgsUsage: "URL",
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
||||||
mfa.setVerbosity(cmd)
|
|
||||||
mfa.printBanner()
|
|
||||||
|
|
||||||
return mfa.fetchManifestOperation(ctx, cmd)
|
|
||||||
},
|
|
||||||
Flags: append(commonFlags(),
|
|
||||||
&cli.DurationFlag{
|
|
||||||
Name: flagTimeout,
|
|
||||||
Value: httpTimeout,
|
|
||||||
Usage: "Time limit for each HTTP request, including the download " +
|
|
||||||
"of its body",
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: flagDest,
|
|
||||||
Aliases: []string{"d"},
|
|
||||||
Value: ".",
|
|
||||||
Usage: "Directory to download the files and the manifest into",
|
|
||||||
},
|
|
||||||
requireSignatureFlag(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfa *CLIApp) run(args []string) {
|
func (mfa *CLIApp) run(args []string) {
|
||||||
mfa.startupTime = time.Now()
|
mfa.startupTime = time.Now()
|
||||||
|
|
||||||
if NoColor {
|
if NO_COLOR {
|
||||||
// shoutout to rob pike who thinks it's juvenile
|
// shoutout to rob pike who thinks it's juvenile
|
||||||
log.DisableStyling()
|
log.DisableStyling()
|
||||||
}
|
}
|
||||||
@@ -416,58 +97,205 @@ func (mfa *CLIApp) run(args []string) {
|
|||||||
log.SetOutput(mfa.Stdout, mfa.Stderr)
|
log.SetOutput(mfa.Stdout, mfa.Stderr)
|
||||||
log.Init()
|
log.Init()
|
||||||
|
|
||||||
// -v means verbose, not version, at the root as on the generate, check,
|
mfa.app = &cli.App{
|
||||||
// freshen and fetch subcommands: verbose is the more common meaning of -v
|
Name: mfa.appname,
|
||||||
// in tools that offer both. urfave/cli's built-in version flag claims -v
|
Usage: "Manifest generator",
|
||||||
// by default, so the version flag takes the capital -V instead.
|
Version: mfa.VersionString(),
|
||||||
// VersionFlag and VersionPrinter are urfave/cli package globals; run() is
|
EnableBashCompletion: true,
|
||||||
// serialized in tests, so assigning them here is safe.
|
Writer: mfa.Stdout,
|
||||||
cli.VersionFlag = &cli.BoolFlag{
|
ErrWriter: mfa.Stderr,
|
||||||
Name: cmdVersion,
|
Action: func(c *cli.Context) error {
|
||||||
Aliases: []string{"V"},
|
if c.Args().Len() > 0 {
|
||||||
Usage: "print the version",
|
return fmt.Errorf("unknown command %q", c.Args().First())
|
||||||
Local: true,
|
|
||||||
}
|
|
||||||
cli.VersionPrinter = func(*cli.Command) { mfa.printVersion() }
|
|
||||||
|
|
||||||
mfa.app = &cli.Command{
|
|
||||||
Name: mfa.appname,
|
|
||||||
Usage: "Manifest generator",
|
|
||||||
Version: mfa.VersionString(),
|
|
||||||
EnableShellCompletion: true,
|
|
||||||
Writer: mfa.Stdout,
|
|
||||||
// v3 writes its "Incorrect Usage" line to ErrWriter; v2 wrote it to
|
|
||||||
// stdout, before the help, so it stays on stdout.
|
|
||||||
ErrWriter: mfa.Stdout,
|
|
||||||
Flags: commonFlags(),
|
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(_ context.Context, cmd *cli.Command) error {
|
|
||||||
if cmd.Args().Len() > 0 {
|
|
||||||
return fmt.Errorf("%w %q", errUnknownCommand, cmd.Args().First())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mfa.setVerbosity(cmd)
|
|
||||||
mfa.printBanner()
|
mfa.printBanner()
|
||||||
|
return cli.ShowAppHelp(c)
|
||||||
return cli.ShowRootCommandHelp(cmd)
|
|
||||||
},
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
mfa.generateCommand(),
|
{
|
||||||
mfa.checkCommand(),
|
Name: "generate",
|
||||||
mfa.freshenCommand(),
|
Aliases: []string{"gen"},
|
||||||
mfa.exportCommand(),
|
Usage: "Generate manifest file",
|
||||||
mfa.versionCommand(),
|
Action: func(c *cli.Context) error {
|
||||||
mfa.listCommand(),
|
mfa.setVerbosity(c)
|
||||||
mfa.fetchCommand(),
|
mfa.printBanner()
|
||||||
|
return mfa.generateManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: append(commonFlags(),
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "follow-symlinks",
|
||||||
|
Aliases: []string{"L"},
|
||||||
|
Usage: "Resolve encountered symlinks",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-dotfiles",
|
||||||
|
Aliases: []string{"IncludeDotfiles"},
|
||||||
|
|
||||||
|
Usage: "Include dot (hidden) files (excluded by default)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "output",
|
||||||
|
Value: "./.index.mf",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
Usage: "Specify output filename",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "force",
|
||||||
|
Aliases: []string{"f"},
|
||||||
|
Usage: "Overwrite output file if it exists",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "progress",
|
||||||
|
Aliases: []string{"P"},
|
||||||
|
Usage: "Show progress during enumeration and scanning",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "sign-key",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "GPG key ID to sign the manifest with",
|
||||||
|
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "seed",
|
||||||
|
Usage: "Seed value for deterministic manifest UUID",
|
||||||
|
EnvVars: []string{"MFER_SEED"},
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-timestamps",
|
||||||
|
Usage: "Include createdAt timestamp in manifest (omitted by default for determinism)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "check",
|
||||||
|
Usage: "Validate files using manifest file",
|
||||||
|
ArgsUsage: "[manifest file]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.checkManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: append(commonFlags(),
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "base",
|
||||||
|
Aliases: []string{"b"},
|
||||||
|
Value: ".",
|
||||||
|
Usage: "Base directory for resolving relative paths from manifest",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "progress",
|
||||||
|
Aliases: []string{"P"},
|
||||||
|
Usage: "Show progress during checking",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-extra-files",
|
||||||
|
Usage: "Fail if files exist in base directory that are not in manifest",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "require-signature",
|
||||||
|
Aliases: []string{"S"},
|
||||||
|
Usage: "Require manifest to be signed by the specified GPG key ID",
|
||||||
|
EnvVars: []string{"MFER_REQUIRE_SIGNATURE"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "freshen",
|
||||||
|
Usage: "Update manifest with changed, new, and removed files",
|
||||||
|
ArgsUsage: "[manifest file]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.freshenManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: append(commonFlags(),
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "base",
|
||||||
|
Aliases: []string{"b"},
|
||||||
|
Value: ".",
|
||||||
|
Usage: "Base directory for resolving relative paths",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "follow-symlinks",
|
||||||
|
Aliases: []string{"L"},
|
||||||
|
Usage: "Resolve encountered symlinks",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-dotfiles",
|
||||||
|
Aliases: []string{"IncludeDotfiles"},
|
||||||
|
|
||||||
|
Usage: "Include dot (hidden) files (excluded by default)",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "progress",
|
||||||
|
Aliases: []string{"P"},
|
||||||
|
Usage: "Show progress during scanning and hashing",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "sign-key",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "GPG key ID to sign the manifest with",
|
||||||
|
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-timestamps",
|
||||||
|
Usage: "Include createdAt timestamp in manifest (omitted by default for determinism)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "export",
|
||||||
|
Usage: "Export manifest contents as JSON",
|
||||||
|
ArgsUsage: "[manifest file or URL]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return mfa.exportManifestOperation(c)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "version",
|
||||||
|
Usage: "Show version",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
_, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Usage: "List files in manifest",
|
||||||
|
ArgsUsage: "[manifest file]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return mfa.listManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "long",
|
||||||
|
Aliases: []string{"l"},
|
||||||
|
Usage: "Show size and mtime",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "print0",
|
||||||
|
Usage: "Separate entries with NUL character (for xargs -0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fetch",
|
||||||
|
Usage: "fetch manifest and referenced files",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.fetchManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: commonFlags(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
mfa.app.HideVersion = false
|
mfa.app.HideVersion = false
|
||||||
|
err := mfa.app.Run(args)
|
||||||
err := mfa.app.Run(context.Background(), args)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mfa.exitCode = 1
|
mfa.exitCode = 1
|
||||||
|
log.WithError(err).Debugf("exiting")
|
||||||
log.Errorf("%s", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/mfer/mfer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mtimeAbsent is printed in place of a modification time when a manifest
|
|
||||||
// entry does not carry one.
|
|
||||||
const mtimeAbsent = "-"
|
|
||||||
|
|
||||||
// entryMtime returns the modification time recorded for a manifest entry.
|
|
||||||
//
|
|
||||||
// MFFilePath.Mtime is a message pointer with proto3 field presence, so an
|
|
||||||
// absent mtime is a representable, on-the-wire-valid state. It must never
|
|
||||||
// be conflated with a recorded mtime of the Unix epoch: callers that
|
|
||||||
// compare mtimes have to treat "absent" as "unknown", not as
|
|
||||||
// 1970-01-01T00:00:00Z, or every entry compares as modified. ok reports
|
|
||||||
// whether an mtime was actually recorded.
|
|
||||||
func entryMtime(entry *mfer.MFFilePath) (time.Time, bool) {
|
|
||||||
ts := entry.GetMtime()
|
|
||||||
if ts == nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Unix(ts.GetSeconds(), int64(ts.GetNanos())), true
|
|
||||||
}
|
|
||||||
Vendored
BIN
Binary file not shown.
+77
-183
@@ -1,25 +1,17 @@
|
|||||||
// Package log provides leveled logging on top of log/slog, and helpers that
|
|
||||||
// print progress lines which overwrite each other in place on a terminal.
|
|
||||||
//
|
|
||||||
// Until Init runs, log records go to slog.Default(), so a program that uses
|
|
||||||
// package mfer as a library gets them wherever it sends its own slog output.
|
|
||||||
// Init switches to the CLI's format on the stderr writer given to SetOutput.
|
|
||||||
// Progress lines are not log records: they are written straight to the stdout
|
|
||||||
// writer, never through slog.
|
|
||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/apex/log"
|
||||||
|
acli "github.com/apex/log/handlers/cli"
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
"golang.org/x/term"
|
"github.com/pterm/pterm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Level represents log severity levels.
|
// Level represents log severity levels.
|
||||||
@@ -60,109 +52,31 @@ func (l Level) String() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callerSkip is the runtime.Caller stack depth from the public Debug
|
|
||||||
// helpers to the caller of the log package.
|
|
||||||
const callerSkip = 2
|
|
||||||
|
|
||||||
// Escape sequences for colored log lines.
|
|
||||||
const (
|
|
||||||
ansiReset = "\x1b[0m"
|
|
||||||
ansiBold = "\x1b[1m"
|
|
||||||
ansiRed = "\x1b[31m"
|
|
||||||
ansiYellow = "\x1b[33m"
|
|
||||||
ansiBlue = "\x1b[34m"
|
|
||||||
ansiWhite = "\x1b[37m"
|
|
||||||
)
|
|
||||||
|
|
||||||
//nolint:gochecknoglobals // package-level logger state by design
|
|
||||||
var (
|
var (
|
||||||
// mu protects the variables below
|
// mu protects the output writers and level
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
// stdout is the writer for progress output
|
// stdout is the writer for progress output
|
||||||
stdout io.Writer = os.Stdout
|
stdout io.Writer = os.Stdout
|
||||||
// stderr is the writer for log output
|
// stderr is the writer for log output
|
||||||
stderr io.Writer = os.Stderr
|
stderr io.Writer = os.Stderr
|
||||||
// styled is false once DisableStyling has been called
|
|
||||||
styled = true
|
|
||||||
// logger is the CLI logger Init builds; nil until Init runs
|
|
||||||
logger *slog.Logger
|
|
||||||
// currentLevel is our log level (includes Verbose)
|
// currentLevel is our log level (includes Verbose)
|
||||||
currentLevel = InfoLevel
|
currentLevel Level = InfoLevel
|
||||||
)
|
)
|
||||||
|
|
||||||
// cliHandler is the slog.Handler the CLI logs through. Each record is one
|
|
||||||
// line: a symbol for its level right-aligned in four columns, then the
|
|
||||||
// message padded to 25 columns. In color, only the symbol is bold and in the
|
|
||||||
// level's color; the message is in the terminal's default color. Records are
|
|
||||||
// filtered by level before they are made, so the handler takes every record
|
|
||||||
// it is given.
|
|
||||||
type cliHandler struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
w io.Writer
|
|
||||||
color bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled reports true for every level.
|
|
||||||
func (h *cliHandler) Enabled(context.Context, slog.Level) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle writes the record as one line.
|
|
||||||
func (h *cliHandler) Handle(_ context.Context, r slog.Record) error {
|
|
||||||
symbol, color := "•", ansiBlue
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case r.Level >= slog.LevelError:
|
|
||||||
symbol, color = "⨯", ansiRed
|
|
||||||
case r.Level >= slog.LevelWarn:
|
|
||||||
color = ansiYellow
|
|
||||||
case r.Level < slog.LevelInfo:
|
|
||||||
color = ansiWhite
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
if !h.color {
|
|
||||||
_, err := fmt.Fprintf(h.w, "%4s %-25s\n", symbol, r.Message)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fmt.Fprintf(h.w, "%s%s%4s%s %-25s%s\n",
|
|
||||||
color, ansiBold, symbol, ansiReset, r.Message, ansiReset)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithAttrs returns the handler unchanged: this package's helpers never
|
|
||||||
// attach attributes.
|
|
||||||
func (h *cliHandler) WithAttrs([]slog.Attr) slog.Handler {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithGroup returns the handler unchanged: this package's helpers never open
|
|
||||||
// groups.
|
|
||||||
func (h *cliHandler) WithGroup(string) slog.Handler {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOutput configures the output writers for the log package.
|
// SetOutput configures the output writers for the log package.
|
||||||
// stdout is used for progress output, stderr is used for log messages
|
// stdout is used for progress output, stderr is used for log messages.
|
||||||
// from the next Init on.
|
|
||||||
func SetOutput(out, err io.Writer) {
|
func SetOutput(out, err io.Writer) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
stdout = out
|
stdout = out
|
||||||
stderr = err
|
stderr = err
|
||||||
|
pterm.SetDefaultOutput(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStdout returns the configured stdout writer.
|
// GetStdout returns the configured stdout writer.
|
||||||
func GetStdout() io.Writer {
|
func GetStdout() io.Writer {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
||||||
return stdout
|
return stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,154 +84,138 @@ func GetStdout() io.Writer {
|
|||||||
func GetStderr() io.Writer {
|
func GetStderr() io.Writer {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
||||||
return stderr
|
return stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisableStyling turns off colors in log lines from the next Init on.
|
// DisableStyling turns off colors and styling for terminal output.
|
||||||
func DisableStyling() {
|
func DisableStyling() {
|
||||||
mu.Lock()
|
pterm.DisableColor()
|
||||||
defer mu.Unlock()
|
pterm.DisableStyling()
|
||||||
|
pterm.Debug.Prefix.Text = ""
|
||||||
styled = false
|
pterm.Info.Prefix.Text = ""
|
||||||
|
pterm.Success.Prefix.Text = ""
|
||||||
|
pterm.Warning.Prefix.Text = ""
|
||||||
|
pterm.Error.Prefix.Text = ""
|
||||||
|
pterm.Fatal.Prefix.Text = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init sends log records to the CLI handler on the stderr writer given to
|
// Init initializes the logger with the CLI handler and default log level.
|
||||||
// SetOutput. Log lines are colored when stdout is a terminal whose TERM is
|
|
||||||
// not dumb, unless DisableStyling has been called.
|
|
||||||
//
|
|
||||||
// It replaces the logger under the write lock, and log calls hold the read
|
|
||||||
// lock while they write, so once Init returns nothing writes through the
|
|
||||||
// logger it replaced.
|
|
||||||
func Init() {
|
func Init() {
|
||||||
mu.Lock()
|
mu.RLock()
|
||||||
defer mu.Unlock()
|
w := stderr
|
||||||
|
mu.RUnlock()
|
||||||
color := styled && os.Getenv("TERM") != "dumb" &&
|
log.SetHandler(acli.New(w))
|
||||||
term.IsTerminal(int(os.Stdout.Fd()))
|
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
|
||||||
|
|
||||||
logger = slog.New(&cliHandler{w: stderr, color: color})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isEnabled returns true if messages at the given level should be logged.
|
// isEnabled returns true if messages at the given level should be logged.
|
||||||
func isEnabled(l Level) bool {
|
func isEnabled(l Level) bool {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
||||||
return l >= currentLevel
|
return l >= currentLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
// logf logs a formatted message at level l if messages at l are enabled,
|
// Fatalf logs a formatted message at fatal level.
|
||||||
// holding the read lock while the record is written. slog has no verbose or
|
func Fatalf(format string, args ...interface{}) {
|
||||||
// fatal level: verbose messages are logged as info records, and fatal
|
if isEnabled(FatalLevel) {
|
||||||
// messages as error records.
|
log.Fatalf(format, args...)
|
||||||
func logf(l Level, format string, args ...any) {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if l < currentLevel {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lg := logger
|
|
||||||
if lg == nil {
|
|
||||||
lg = slog.Default()
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := fmt.Sprintf(format, args...)
|
|
||||||
|
|
||||||
switch l {
|
|
||||||
case DebugLevel:
|
|
||||||
lg.Debug(msg)
|
|
||||||
case VerboseLevel, InfoLevel:
|
|
||||||
lg.Info(msg)
|
|
||||||
case WarnLevel:
|
|
||||||
lg.Warn(msg)
|
|
||||||
case ErrorLevel, FatalLevel:
|
|
||||||
lg.Error(msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatalf logs a formatted message at fatal level, then exits with status 1.
|
// Fatal logs a message at fatal level.
|
||||||
func Fatalf(format string, args ...any) {
|
|
||||||
logf(FatalLevel, format, args...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fatal logs a message at fatal level, then exits with status 1.
|
|
||||||
func Fatal(arg string) {
|
func Fatal(arg string) {
|
||||||
logf(FatalLevel, "%s", arg)
|
if isEnabled(FatalLevel) {
|
||||||
os.Exit(1)
|
log.Fatal(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs a formatted message at error level.
|
// Errorf logs a formatted message at error level.
|
||||||
func Errorf(format string, args ...any) {
|
func Errorf(format string, args ...interface{}) {
|
||||||
logf(ErrorLevel, format, args...)
|
if isEnabled(ErrorLevel) {
|
||||||
|
log.Errorf(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs a message at error level.
|
// Error logs a message at error level.
|
||||||
func Error(arg string) {
|
func Error(arg string) {
|
||||||
logf(ErrorLevel, "%s", arg)
|
if isEnabled(ErrorLevel) {
|
||||||
|
log.Error(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warnf logs a formatted message at warn level.
|
// Warnf logs a formatted message at warn level.
|
||||||
func Warnf(format string, args ...any) {
|
func Warnf(format string, args ...interface{}) {
|
||||||
logf(WarnLevel, format, args...)
|
if isEnabled(WarnLevel) {
|
||||||
|
log.Warnf(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs a message at warn level.
|
// Warn logs a message at warn level.
|
||||||
func Warn(arg string) {
|
func Warn(arg string) {
|
||||||
logf(WarnLevel, "%s", arg)
|
if isEnabled(WarnLevel) {
|
||||||
|
log.Warn(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs a formatted message at info level.
|
// Infof logs a formatted message at info level.
|
||||||
func Infof(format string, args ...any) {
|
func Infof(format string, args ...interface{}) {
|
||||||
logf(InfoLevel, format, args...)
|
if isEnabled(InfoLevel) {
|
||||||
|
log.Infof(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs a message at info level.
|
// Info logs a message at info level.
|
||||||
func Info(arg string) {
|
func Info(arg string) {
|
||||||
logf(InfoLevel, "%s", arg)
|
if isEnabled(InfoLevel) {
|
||||||
|
log.Info(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbosef logs a formatted message at verbose level.
|
// Verbosef logs a formatted message at verbose level.
|
||||||
func Verbosef(format string, args ...any) {
|
func Verbosef(format string, args ...interface{}) {
|
||||||
logf(VerboseLevel, format, args...)
|
if isEnabled(VerboseLevel) {
|
||||||
|
log.Infof(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbose logs a message at verbose level.
|
// Verbose logs a message at verbose level.
|
||||||
func Verbose(arg string) {
|
func Verbose(arg string) {
|
||||||
logf(VerboseLevel, "%s", arg)
|
if isEnabled(VerboseLevel) {
|
||||||
|
log.Info(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs a formatted message at debug level with caller location.
|
// Debugf logs a formatted message at debug level with caller location.
|
||||||
func Debugf(format string, args ...any) {
|
func Debugf(format string, args ...interface{}) {
|
||||||
if isEnabled(DebugLevel) {
|
if isEnabled(DebugLevel) {
|
||||||
DebugReal(fmt.Sprintf(format, args...), callerSkip)
|
DebugReal(fmt.Sprintf(format, args...), 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a message at debug level with caller location.
|
// Debug logs a message at debug level with caller location.
|
||||||
func Debug(arg string) {
|
func Debug(arg string) {
|
||||||
if isEnabled(DebugLevel) {
|
if isEnabled(DebugLevel) {
|
||||||
DebugReal(arg, callerSkip)
|
DebugReal(arg, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DebugReal logs at debug level with caller info from the specified stack depth.
|
// DebugReal logs at debug level with caller info from the specified stack depth.
|
||||||
func DebugReal(arg string, cs int) {
|
func DebugReal(arg string, cs int) {
|
||||||
|
if !isEnabled(DebugLevel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
_, callerFile, callerLine, ok := runtime.Caller(cs)
|
_, callerFile, callerLine, ok := runtime.Caller(cs)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tag := fmt.Sprintf("%s:%d: ", filepath.Base(callerFile), callerLine)
|
||||||
logf(DebugLevel, "%s:%d: %s", filepath.Base(callerFile), callerLine, arg)
|
log.Debug(tag + arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dump logs a spew dump of the arguments at debug level.
|
// Dump logs a spew dump of the arguments at debug level.
|
||||||
func Dump(args ...any) {
|
func Dump(args ...interface{}) {
|
||||||
if isEnabled(DebugLevel) {
|
if isEnabled(DebugLevel) {
|
||||||
DebugReal(spew.Sdump(args...), callerSkip)
|
DebugReal(spew.Sdump(args...), 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +246,6 @@ func SetLevelFromVerbosity(l int) {
|
|||||||
func SetLevel(l Level) {
|
func SetLevel(l Level) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
currentLevel = l
|
currentLevel = l
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,25 +253,22 @@ func SetLevel(l Level) {
|
|||||||
func GetLevel() Level {
|
func GetLevel() Level {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
||||||
return currentLevel
|
return currentLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithError returns a log entry with the error attached.
|
||||||
|
func WithError(e error) *log.Entry {
|
||||||
|
return log.Log.WithError(e)
|
||||||
|
}
|
||||||
|
|
||||||
// Progressf prints a progress message that overwrites the current line.
|
// Progressf prints a progress message that overwrites the current line.
|
||||||
// Use ProgressDone() when progress is complete to move to the next line.
|
// Use ProgressDone() when progress is complete to move to the next line.
|
||||||
// Progress goes to the stdout writer whatever the log level.
|
func Progressf(format string, args ...interface{}) {
|
||||||
func Progressf(format string, args ...any) {
|
pterm.Printf("\r"+format, args...)
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(stdout, "\r"+format, args...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressDone clears the progress line when progress is complete.
|
// ProgressDone clears the progress line when progress is complete.
|
||||||
func ProgressDone() {
|
func ProgressDone() {
|
||||||
mu.Lock()
|
// Clear the line with spaces and return to beginning
|
||||||
defer mu.Unlock()
|
pterm.Print("\r\033[K")
|
||||||
|
|
||||||
// Return to the start of the line and erase it
|
|
||||||
_, _ = fmt.Fprint(stdout, "\r\033[K")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-218
@@ -1,229 +1,12 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
stdlog "log"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuild(t *testing.T) {
|
func TestBuild(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
Init()
|
Init()
|
||||||
}
|
assert.True(t, true)
|
||||||
|
|
||||||
// capture points the package's writers at fresh buffers and sets its level,
|
|
||||||
// with colors off, then restores the default writers and level when the test
|
|
||||||
// ends. The state it changes is process-wide, so tests that use it do not
|
|
||||||
// run in parallel.
|
|
||||||
func capture(t *testing.T, l Level) (*bytes.Buffer, *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
|
|
||||||
DisableStyling()
|
|
||||||
SetOutput(&stdout, &stderr)
|
|
||||||
SetLevel(l)
|
|
||||||
Init()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
SetOutput(os.Stdout, os.Stderr)
|
|
||||||
SetLevel(InfoLevel)
|
|
||||||
Init()
|
|
||||||
})
|
|
||||||
|
|
||||||
return &stdout, &stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLevelFiltering logs at every level, through both the plain and the
|
|
||||||
// formatting helpers, and checks that exactly the messages at or above the
|
|
||||||
// set level are written.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the package's process-wide writers and level
|
|
||||||
func TestLevelFiltering(t *testing.T) {
|
|
||||||
messages := []struct {
|
|
||||||
level Level
|
|
||||||
text string
|
|
||||||
}{
|
|
||||||
{DebugLevel, "debug plain"},
|
|
||||||
{DebugLevel, "debug formatted"},
|
|
||||||
{VerboseLevel, "verbose plain"},
|
|
||||||
{VerboseLevel, "verbose formatted"},
|
|
||||||
{InfoLevel, "info plain"},
|
|
||||||
{InfoLevel, "info formatted"},
|
|
||||||
{WarnLevel, "warn plain"},
|
|
||||||
{WarnLevel, "warn formatted"},
|
|
||||||
{ErrorLevel, "error plain"},
|
|
||||||
{ErrorLevel, "error formatted"},
|
|
||||||
}
|
|
||||||
|
|
||||||
levels := []Level{DebugLevel, VerboseLevel, InfoLevel, WarnLevel, ErrorLevel}
|
|
||||||
|
|
||||||
for _, set := range levels {
|
|
||||||
t.Run(set.String(), func(t *testing.T) {
|
|
||||||
stdout, stderr := capture(t, set)
|
|
||||||
|
|
||||||
Debug("debug plain")
|
|
||||||
Debugf("debug %s", "formatted")
|
|
||||||
Verbose("verbose plain")
|
|
||||||
Verbosef("verbose %s", "formatted")
|
|
||||||
Info("info plain")
|
|
||||||
Infof("info %s", "formatted")
|
|
||||||
Warn("warn plain")
|
|
||||||
Warnf("warn %s", "formatted")
|
|
||||||
Error("error plain")
|
|
||||||
Errorf("error %s", "formatted")
|
|
||||||
|
|
||||||
for _, m := range messages {
|
|
||||||
if m.level >= set {
|
|
||||||
assert.Contains(t, stderr.String(), m.text)
|
|
||||||
} else {
|
|
||||||
assert.NotContains(t, stderr.String(), m.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Empty(t, stdout.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLineFormat checks the exact uncolored lines: a symbol per level
|
|
||||||
// right-aligned in four columns, then the message padded to 25 columns.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the package's process-wide writers and level
|
|
||||||
func TestLineFormat(t *testing.T) {
|
|
||||||
_, stderr := capture(t, VerboseLevel)
|
|
||||||
|
|
||||||
Infof("scanning filesystem...")
|
|
||||||
Verbosef("+ %s (%s)", "a.txt", "1 B")
|
|
||||||
Warn("short")
|
|
||||||
Errorf("%s", "a message longer than twenty-five columns")
|
|
||||||
|
|
||||||
want := " • scanning filesystem... \n" +
|
|
||||||
" • + a.txt (1 B) \n" +
|
|
||||||
" • short \n" +
|
|
||||||
" ⨯ a message longer than twenty-five columns\n"
|
|
||||||
assert.Equal(t, want, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDebugCallerTag checks that debug lines start with the file and line
|
|
||||||
// of the call that logged them.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the package's process-wide writers and level
|
|
||||||
func TestDebugCallerTag(t *testing.T) {
|
|
||||||
_, stderr := capture(t, DebugLevel)
|
|
||||||
|
|
||||||
Debugf("enumerating path: %s", "/tmp")
|
|
||||||
|
|
||||||
assert.Regexp(t, `^ • log_test\.go:\d+: enumerating path: /tmp\s*\n$`,
|
|
||||||
stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestColoredLine checks the colored lines: only the symbol is bold and in the
|
|
||||||
// level's color; the message is in the terminal's default color.
|
|
||||||
func TestColoredLine(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
lg := slog.New(&cliHandler{w: &buf, color: true})
|
|
||||||
lg.Debug("debug")
|
|
||||||
lg.Info("info")
|
|
||||||
lg.Warn("warn")
|
|
||||||
lg.Error("error")
|
|
||||||
|
|
||||||
want := "\x1b[37m\x1b[1m •\x1b[0m debug \x1b[0m\n" +
|
|
||||||
"\x1b[34m\x1b[1m •\x1b[0m info \x1b[0m\n" +
|
|
||||||
"\x1b[33m\x1b[1m •\x1b[0m warn \x1b[0m\n" +
|
|
||||||
"\x1b[31m\x1b[1m ⨯\x1b[0m error \x1b[0m\n"
|
|
||||||
assert.Equal(t, want, buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecordLevels checks the slog level of the record each helper logs,
|
|
||||||
// through slog's text handler with the time left out. slog has no verbose
|
|
||||||
// level, so verbose messages are info records.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the package's process-wide logger and level
|
|
||||||
func TestRecordLevels(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
capture(t, DebugLevel)
|
|
||||||
|
|
||||||
opts := &slog.HandlerOptions{
|
|
||||||
Level: slog.LevelDebug,
|
|
||||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
|
||||||
if a.Key == slog.TimeKey {
|
|
||||||
return slog.Attr{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return a
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
logger = slog.New(slog.NewTextHandler(&buf, opts))
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
Debugf("debug")
|
|
||||||
Verbosef("verbose")
|
|
||||||
Infof("info")
|
|
||||||
Warnf("warn")
|
|
||||||
Errorf("error")
|
|
||||||
|
|
||||||
assert.Regexp(t, `^level=DEBUG msg="log_test\.go:\d+: debug"\n`+
|
|
||||||
"level=INFO msg=verbose\n"+
|
|
||||||
"level=INFO msg=info\n"+
|
|
||||||
"level=WARN msg=warn\n"+
|
|
||||||
"level=ERROR msg=error\n$", buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProgress checks that progress lines go to the stdout writer whatever
|
|
||||||
// the log level, each starting with a carriage return so it overwrites the
|
|
||||||
// last, and that ProgressDone erases the line.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the package's process-wide writers and level
|
|
||||||
func TestProgress(t *testing.T) {
|
|
||||||
stdout, stderr := capture(t, ErrorLevel)
|
|
||||||
|
|
||||||
Progressf("Scanning: %d files found", 1000)
|
|
||||||
Progressf("Scanning: %d files found", 2000)
|
|
||||||
ProgressDone()
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
"\rScanning: 1000 files found\rScanning: 2000 files found\r\x1b[K",
|
|
||||||
stdout.String())
|
|
||||||
assert.Empty(t, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBeforeInit checks that records go to slog.Default() until Init runs,
|
|
||||||
// still filtered by the package's level. slog's default handler writes
|
|
||||||
// through the standard library's log package.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the package's process-wide logger
|
|
||||||
func TestBeforeInit(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
flags := stdlog.Flags()
|
|
||||||
|
|
||||||
stdlog.SetOutput(&buf)
|
|
||||||
stdlog.SetFlags(0)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
logger = nil
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stdlog.SetOutput(os.Stderr)
|
|
||||||
stdlog.SetFlags(flags)
|
|
||||||
Init()
|
|
||||||
})
|
|
||||||
|
|
||||||
Infof("loaded manifest with %d files", 3)
|
|
||||||
Verbose("not shown at info level")
|
|
||||||
|
|
||||||
assert.Equal(t, "INFO loaded manifest with 3 files\n", buf.String())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-105
@@ -1,14 +1,10 @@
|
|||||||
// Package mfer implements the mfer manifest file format: building,
|
|
||||||
// serializing, verifying, and checking manifests of file trees.
|
|
||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,28 +14,6 @@ import (
|
|||||||
"github.com/multiformats/go-multihash"
|
"github.com/multiformats/go-multihash"
|
||||||
)
|
)
|
||||||
|
|
||||||
// readChunkSize is the buffer size used when reading file contents for
|
|
||||||
// hashing.
|
|
||||||
const readChunkSize = 64 * 1024
|
|
||||||
|
|
||||||
// The errPath* sentinels below are worded as the trailing fragment of the
|
|
||||||
// message ValidatePath renders, because the offending path is quoted
|
|
||||||
// before them (`path %q ...`). Wrapping them mid-sentence keeps the
|
|
||||||
// rendered text exactly as mfer has always printed it. Match them with
|
|
||||||
// errors.Is rather than by reading their messages.
|
|
||||||
var (
|
|
||||||
errPathEmpty = errors.New("path cannot be empty")
|
|
||||||
errPathNotUTF8 = errors.New("is not valid UTF-8")
|
|
||||||
errPathBackslash = errors.New("contains backslash; use forward slashes only")
|
|
||||||
errPathAbsolute = errors.New("is absolute; must be relative")
|
|
||||||
errPathEmptySegment = errors.New("contains empty segment")
|
|
||||||
errPathDotDot = errors.New("contains '..' segment")
|
|
||||||
errSizeMismatch = errors.New("size mismatch")
|
|
||||||
errNegativeSize = errors.New("size cannot be negative")
|
|
||||||
errHashNotMultihash = errors.New("hash is not a valid multihash")
|
|
||||||
errHashTooShort = errors.New("hash digest is too short")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValidatePath checks that a file path conforms to manifest path invariants:
|
// ValidatePath checks that a file path conforms to manifest path invariants:
|
||||||
// - Must be valid UTF-8
|
// - Must be valid UTF-8
|
||||||
// - Must use forward slashes only (no backslashes)
|
// - Must use forward slashes only (no backslashes)
|
||||||
@@ -49,31 +23,25 @@ var (
|
|||||||
// - Must not be empty
|
// - Must not be empty
|
||||||
func ValidatePath(p string) error {
|
func ValidatePath(p string) error {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return errPathEmpty
|
return errors.New("path cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !utf8.ValidString(p) {
|
if !utf8.ValidString(p) {
|
||||||
return fmt.Errorf("path %q %w", p, errPathNotUTF8)
|
return fmt.Errorf("path %q is not valid UTF-8", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ContainsRune(p, '\\') {
|
if strings.ContainsRune(p, '\\') {
|
||||||
return fmt.Errorf("path %q %w", p, errPathBackslash)
|
return fmt.Errorf("path %q contains backslash; use forward slashes only", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(p, "/") {
|
if strings.HasPrefix(p, "/") {
|
||||||
return fmt.Errorf("path %q %w", p, errPathAbsolute)
|
return fmt.Errorf("path %q is absolute; must be relative", p)
|
||||||
}
|
}
|
||||||
|
for _, seg := range strings.Split(p, "/") {
|
||||||
for seg := range strings.SplitSeq(p, "/") {
|
|
||||||
if seg == "" {
|
if seg == "" {
|
||||||
return fmt.Errorf("path %q %w", p, errPathEmptySegment)
|
return fmt.Errorf("path %q contains empty segment", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
if seg == ".." {
|
if seg == ".." {
|
||||||
return fmt.Errorf("path %q %w", p, errPathDotDot)
|
return fmt.Errorf("path %q contains '..' segment", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +68,11 @@ type UnixNanos int32
|
|||||||
|
|
||||||
// Timestamp converts ModTime to a protobuf Timestamp.
|
// Timestamp converts ModTime to a protobuf Timestamp.
|
||||||
func (m ModTime) Timestamp() *Timestamp {
|
func (m ModTime) Timestamp() *Timestamp {
|
||||||
return newTimestampFromTime(time.Time(m))
|
t := time.Time(m)
|
||||||
|
return &Timestamp{
|
||||||
|
Seconds: t.Unix(),
|
||||||
|
Nanos: int32(t.Nanosecond()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
||||||
@@ -121,6 +93,14 @@ type Builder struct {
|
|||||||
fixedUUID []byte // if set, use this UUID instead of generating one
|
fixedUUID []byte // if set, use this UUID instead of generating one
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSeed derives a deterministic UUID from the given seed string.
|
||||||
|
// The seed is hashed once with SHA-256 and the first 16 bytes are used
|
||||||
|
// as a fixed UUID for the manifest.
|
||||||
|
func (b *Builder) SetSeed(seed string) {
|
||||||
|
hash := sha256.Sum256([]byte(seed))
|
||||||
|
b.fixedUUID = hash[:16]
|
||||||
|
}
|
||||||
|
|
||||||
// NewBuilder creates a new Builder.
|
// NewBuilder creates a new Builder.
|
||||||
func NewBuilder() *Builder {
|
func NewBuilder() *Builder {
|
||||||
return &Builder{
|
return &Builder{
|
||||||
@@ -129,28 +109,17 @@ func NewBuilder() *Builder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSeed derives a deterministic UUID from the given seed string.
|
|
||||||
// The seed is hashed once with SHA-256 and the first 16 bytes are used
|
|
||||||
// as a fixed UUID for the manifest.
|
|
||||||
func (b *Builder) SetSeed(seed string) {
|
|
||||||
hash := sha256.Sum256([]byte(seed))
|
|
||||||
b.fixedUUID = hash[:uuidLength]
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFile reads file content from reader, computes hashes, and adds to manifest.
|
// AddFile reads file content from reader, computes hashes, and adds to manifest.
|
||||||
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
|
||||||
// Progress updates are sent to the progress channel (if non-nil) without blocking.
|
// Progress updates are sent to the progress channel (if non-nil) without blocking.
|
||||||
// Returns the number of bytes read.
|
// Returns the number of bytes read.
|
||||||
func (b *Builder) AddFile(
|
func (b *Builder) AddFile(
|
||||||
path RelFilePath,
|
path RelFilePath,
|
||||||
size FileSize,
|
size FileSize,
|
||||||
mtime ModTime,
|
mtime ModTime,
|
||||||
mode fs.FileMode,
|
|
||||||
reader io.Reader,
|
reader io.Reader,
|
||||||
progress chan<- FileHashProgress,
|
progress chan<- FileHashProgress,
|
||||||
) (FileSize, error) {
|
) (FileSize, error) {
|
||||||
err := ValidatePath(string(path))
|
if err := ValidatePath(string(path)); err != nil {
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +128,7 @@ func (b *Builder) AddFile(
|
|||||||
|
|
||||||
// Read file in chunks, updating hash and progress
|
// Read file in chunks, updating hash and progress
|
||||||
var totalRead FileSize
|
var totalRead FileSize
|
||||||
|
buf := make([]byte, 64*1024) // 64KB chunks
|
||||||
buf := make([]byte, readChunkSize)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
n, err := reader.Read(buf)
|
n, err := reader.Read(buf)
|
||||||
@@ -169,11 +137,9 @@ func (b *Builder) AddFile(
|
|||||||
totalRead += FileSize(n)
|
totalRead += FileSize(n)
|
||||||
sendFileHashProgress(progress, FileHashProgress{BytesRead: totalRead})
|
sendFileHashProgress(progress, FileHashProgress{BytesRead: totalRead})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return totalRead, err
|
return totalRead, err
|
||||||
}
|
}
|
||||||
@@ -181,10 +147,7 @@ func (b *Builder) AddFile(
|
|||||||
|
|
||||||
// Verify actual bytes read matches declared size
|
// Verify actual bytes read matches declared size
|
||||||
if totalRead != size {
|
if totalRead != size {
|
||||||
return totalRead, fmt.Errorf(
|
return totalRead, fmt.Errorf("size mismatch for %q: declared %d bytes but read %d bytes", path, size, totalRead)
|
||||||
"%w for %q: declared %d bytes but read %d bytes",
|
|
||||||
errSizeMismatch, path, size, totalRead,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode hash as multihash (SHA2-256)
|
// Encode hash as multihash (SHA2-256)
|
||||||
@@ -201,7 +164,6 @@ func (b *Builder) AddFile(
|
|||||||
{MultiHash: mh},
|
{MultiHash: mh},
|
||||||
},
|
},
|
||||||
Mtime: mtime.Timestamp(),
|
Mtime: mtime.Timestamp(),
|
||||||
Mode: uint32(mode.Perm()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
@@ -216,7 +178,6 @@ func sendFileHashProgress(ch chan<- FileHashProgress, p FileHashProgress) {
|
|||||||
if ch == nil {
|
if ch == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case ch <- p:
|
case ch <- p:
|
||||||
default:
|
default:
|
||||||
@@ -227,44 +188,21 @@ func sendFileHashProgress(ch chan<- FileHashProgress, p FileHashProgress) {
|
|||||||
func (b *Builder) FileCount() int {
|
func (b *Builder) FileCount() int {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
return len(b.files)
|
return len(b.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddFileWithHash adds a file entry with a pre-computed hash.
|
// AddFileWithHash adds a file entry with a pre-computed hash.
|
||||||
// This is useful when the hash is already known (e.g., from an existing manifest).
|
// This is useful when the hash is already known (e.g., from an existing manifest).
|
||||||
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
// Returns an error if path is empty, size is negative, or hash is nil/empty.
|
||||||
// Returns an error if path is invalid, size is negative, or hash is not a
|
func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime, hash Multihash) error {
|
||||||
// multihash with a digest of at least 32 bytes, as long as SHA-256's.
|
if err := ValidatePath(string(path)); err != nil {
|
||||||
func (b *Builder) AddFileWithHash(
|
|
||||||
path RelFilePath,
|
|
||||||
size FileSize,
|
|
||||||
mtime ModTime,
|
|
||||||
mode fs.FileMode,
|
|
||||||
hash Multihash,
|
|
||||||
) error {
|
|
||||||
err := ValidatePath(string(path))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add file: %w", err)
|
return fmt.Errorf("add file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if size < 0 {
|
if size < 0 {
|
||||||
return errNegativeSize
|
return errors.New("size cannot be negative")
|
||||||
}
|
}
|
||||||
|
if len(hash) == 0 {
|
||||||
decoded, err := multihash.Decode(hash)
|
return errors.New("hash cannot be nil or empty")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %w", errHashNotMultihash, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The reader's limit on decoding cost (maxDecodedGrowth) assumes every
|
|
||||||
// hash is at least as long as a SHA-256 multihash, so a manifest of
|
|
||||||
// shorter ones could fail to load.
|
|
||||||
if len(decoded.Digest) < sha256.Size {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %d bytes, at least %d needed",
|
|
||||||
errHashTooShort, len(decoded.Digest), sha256.Size,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := &MFFilePath{
|
entry := &MFFilePath{
|
||||||
@@ -274,13 +212,11 @@ func (b *Builder) AddFileWithHash(
|
|||||||
{MultiHash: hash},
|
{MultiHash: hash},
|
||||||
},
|
},
|
||||||
Mtime: mtime.Timestamp(),
|
Mtime: mtime.Timestamp(),
|
||||||
Mode: uint32(mode.Perm()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.files = append(b.files, entry)
|
b.files = append(b.files, entry)
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +225,6 @@ func (b *Builder) AddFileWithHash(
|
|||||||
func (b *Builder) SetIncludeTimestamps(include bool) {
|
func (b *Builder) SetIncludeTimestamps(include bool) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
b.includeTimestamps = include
|
b.includeTimestamps = include
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,19 +233,17 @@ func (b *Builder) SetIncludeTimestamps(include bool) {
|
|||||||
func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
b.signingOptions = opts
|
b.signingOptions = opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build finalizes the manifest and writes it to the writer. ctx bounds the
|
// Build finalizes the manifest and writes it to the writer.
|
||||||
// gpg runs that sign the manifest when signing options are set.
|
func (b *Builder) Build(w io.Writer) error {
|
||||||
func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
// Sort files by path for deterministic output
|
// Sort files by path for deterministic output
|
||||||
sort.Slice(b.files, func(i, j int) bool {
|
sort.Slice(b.files, func(i, j int) bool {
|
||||||
return b.files[i].GetPath() < b.files[j].GetPath()
|
return b.files[i].Path < b.files[j].Path
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create inner manifest
|
// Create inner manifest
|
||||||
@@ -330,22 +263,19 @@ func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate outer wrapper
|
// Generate outer wrapper
|
||||||
err := m.generateOuter(ctx)
|
if err := m.generateOuter(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build: generate outer: %w", err)
|
return fmt.Errorf("build: generate outer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate final output
|
// Generate final output
|
||||||
err = m.generate(ctx)
|
if err := m.generate(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build: generate: %w", err)
|
return fmt.Errorf("build: generate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to output
|
// Write to output
|
||||||
_, err = w.Write(m.output.Bytes())
|
_, err := w.Write(m.output.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build: write output: %w", err)
|
return fmt.Errorf("build: write output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-241
@@ -1,145 +1,91 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/multiformats/go-multihash"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testFileName = "file.txt"
|
|
||||||
|
|
||||||
func TestNewBuilder(t *testing.T) {
|
func TestNewBuilder(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
assert.NotNil(t, b)
|
assert.NotNil(t, b)
|
||||||
assert.Equal(t, 0, b.FileCount())
|
assert.Equal(t, 0, b.FileCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFile(t *testing.T) {
|
func TestBuilderAddFile(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := []byte("test content")
|
content := []byte("test content")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
bytesRead, err := b.AddFile(
|
bytesRead, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, FileSize(len(content)), bytesRead)
|
assert.Equal(t, FileSize(len(content)), bytesRead)
|
||||||
assert.Equal(t, 1, b.FileCount())
|
assert.Equal(t, 1, b.FileCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFileWithHash(t *testing.T) {
|
func TestBuilderAddFileWithHash(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
hash := make([]byte, 34) // SHA256 multihash is 34 bytes
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, hash)
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, b.FileCount())
|
assert.Equal(t, 1, b.FileCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sha256Hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Run("empty path", func(t *testing.T) {
|
t.Run("empty path", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
err := b.AddFileWithHash("", 100, ModTime(time.Now()), 0, sha256Hash)
|
hash := make([]byte, 34)
|
||||||
require.Error(t, err)
|
err := b.AddFileWithHash("", 100, ModTime(time.Now()), hash)
|
||||||
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "path")
|
assert.Contains(t, err.Error(), "path")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("negative size", func(t *testing.T) {
|
t.Run("negative size", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), 0, sha256Hash)
|
hash := make([]byte, 34)
|
||||||
require.Error(t, err)
|
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), hash)
|
||||||
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "size")
|
assert.Contains(t, err.Error(), "size")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("valid inputs", func(t *testing.T) {
|
t.Run("nil hash", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, sha256Hash)
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), nil)
|
||||||
require.NoError(t, err)
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hash")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty hash", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), []byte{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hash")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid inputs", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
hash := make([]byte, 34)
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||||
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 1, b.FileCount())
|
assert.Equal(t, 1, b.FileCount())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
truncatedHash, err := multihash.Encode(make([]byte, sha256.Size-1), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
hash Multihash
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{"nil hash", nil, errHashNotMultihash},
|
|
||||||
{"empty hash", []byte{}, errHashNotMultihash},
|
|
||||||
{"one-byte hash", []byte{0x12}, errHashNotMultihash},
|
|
||||||
// A SHA-256 code and 32-byte length, then only two bytes of digest.
|
|
||||||
{"malformed multihash", []byte{0x12, 0x20, 0x01, 0x02}, errHashNotMultihash},
|
|
||||||
// A valid multihash, but its 20-byte SHA-1 digest is too short.
|
|
||||||
{"SHA-1 multihash", sha1Hash, errHashTooShort},
|
|
||||||
// A valid multihash whose 31-byte digest is one byte short.
|
|
||||||
{"31-byte digest", truncatedHash, errHashTooShort},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, tt.hash)
|
|
||||||
require.ErrorIs(t, err, tt.want)
|
|
||||||
assert.Equal(t, 0, b.FileCount())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuilderBuild(t *testing.T) {
|
func TestBuilderBuild(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := []byte("test content")
|
content := []byte("test content")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
err = b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should have magic bytes
|
// Should have magic bytes
|
||||||
@@ -147,8 +93,6 @@ func TestBuilderBuild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewTimestampFromTimeExtremeDate(t *testing.T) {
|
func TestNewTimestampFromTimeExtremeDate(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Regression test: newTimestampFromTime used UnixNano() which panics
|
// Regression test: newTimestampFromTime used UnixNano() which panics
|
||||||
// for dates outside ~1678-2262. Now uses Nanosecond() which is safe.
|
// for dates outside ~1678-2262. Now uses Nanosecond() which is safe.
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -163,19 +107,15 @@ func TestNewTimestampFromTimeExtremeDate(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Should not panic
|
// Should not panic
|
||||||
ts := newTimestampFromTime(tt.time)
|
ts := newTimestampFromTime(tt.time)
|
||||||
assert.Equal(t, tt.time.Unix(), ts.GetSeconds())
|
assert.Equal(t, tt.time.Unix(), ts.Seconds)
|
||||||
assert.Equal(t, tt.time.Nanosecond(), int(ts.GetNanos()))
|
assert.Equal(t, int32(tt.time.Nanosecond()), ts.Nanos)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderDeterministicOutput(t *testing.T) {
|
func TestBuilderDeterministicOutput(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
buildManifest := func() []byte {
|
buildManifest := func() []byte {
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
// Use a fixed createdAt and UUID so output is reproducible
|
// Use a fixed createdAt and UUID so output is reproducible
|
||||||
@@ -195,32 +135,24 @@ func TestBuilderDeterministicOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
r := bytes.NewReader([]byte(f.content))
|
r := bytes.NewReader([]byte(f.content))
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile(RelFilePath(f.path), FileSize(len(f.content)), mtime, r, nil)
|
||||||
RelFilePath(f.path), FileSize(len(f.content)), mtime, 0, r, nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err := b.Build(&buf)
|
||||||
err := b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
out1 := buildManifest()
|
out1 := buildManifest()
|
||||||
out2 := buildManifest()
|
out2 := buildManifest()
|
||||||
assert.Equal(t, out1, out2,
|
assert.Equal(t, out1, out2, "two builds with same input should produce byte-identical output")
|
||||||
"two builds with same input should produce byte-identical output")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetSeedDeterministic(t *testing.T) {
|
func TestSetSeedDeterministic(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b1 := NewBuilder()
|
b1 := NewBuilder()
|
||||||
b1.SetSeed("test-seed-value")
|
b1.SetSeed("test-seed-value")
|
||||||
|
|
||||||
b2 := NewBuilder()
|
b2 := NewBuilder()
|
||||||
b2.SetSeed("test-seed-value")
|
b2.SetSeed("test-seed-value")
|
||||||
assert.Equal(t, b1.fixedUUID, b2.fixedUUID, "same seed should produce same UUID")
|
assert.Equal(t, b1.fixedUUID, b2.fixedUUID, "same seed should produce same UUID")
|
||||||
@@ -228,24 +160,19 @@ func TestSetSeedDeterministic(t *testing.T) {
|
|||||||
|
|
||||||
b3 := NewBuilder()
|
b3 := NewBuilder()
|
||||||
b3.SetSeed("different-seed")
|
b3.SetSeed("different-seed")
|
||||||
assert.NotEqual(t, b1.fixedUUID, b3.fixedUUID,
|
assert.NotEqual(t, b1.fixedUUID, b3.fixedUUID, "different seeds should produce different UUIDs")
|
||||||
"different seeds should produce different UUIDs")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatePath(t *testing.T) {
|
func TestValidatePath(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
valid := []string{
|
valid := []string{
|
||||||
testFileName,
|
"file.txt",
|
||||||
"dir/file.txt",
|
"dir/file.txt",
|
||||||
"a/b/c/d.txt",
|
"a/b/c/d.txt",
|
||||||
"file with spaces.txt",
|
"file with spaces.txt",
|
||||||
"日本語.txt", //nolint:gosmopolitan // deliberately tests non-ASCII UTF-8 paths
|
"日本語.txt",
|
||||||
}
|
}
|
||||||
for _, p := range valid {
|
for _, p := range valid {
|
||||||
t.Run("valid:"+p, func(t *testing.T) {
|
t.Run("valid:"+p, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.NoError(t, ValidatePath(p))
|
assert.NoError(t, ValidatePath(p))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -264,54 +191,42 @@ func TestValidatePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range invalid {
|
for _, tt := range invalid {
|
||||||
t.Run("invalid:"+tt.desc, func(t *testing.T) {
|
t.Run("invalid:"+tt.desc, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Error(t, ValidatePath(tt.path))
|
assert.Error(t, ValidatePath(tt.path))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFileSizeMismatch(t *testing.T) {
|
func TestBuilderAddFileSizeMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := []byte("short")
|
content := []byte("short")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
// Declare wrong size
|
// Declare wrong size
|
||||||
_, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), 0, reader, nil)
|
_, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), reader, nil)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "size mismatch")
|
assert.Contains(t, err.Error(), "size mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFileInvalidPath(t *testing.T) {
|
func TestBuilderAddFileInvalidPath(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := []byte("data")
|
content := []byte("data")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
_, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil)
|
_, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
reader.Reset(content)
|
reader.Reset(content)
|
||||||
_, err = b.AddFile(
|
_, err = b.AddFile("/absolute", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
"/absolute", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
|
|
||||||
)
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderAddFileWithProgress(t *testing.T) {
|
func TestBuilderAddFileWithProgress(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := bytes.Repeat([]byte("x"), 1000)
|
content := bytes.Repeat([]byte("x"), 1000)
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
progress := make(chan FileHashProgress, 100)
|
progress := make(chan FileHashProgress, 100)
|
||||||
|
|
||||||
bytesRead, err := b.AddFile(
|
bytesRead, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, progress)
|
||||||
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, progress,
|
|
||||||
)
|
|
||||||
close(progress)
|
close(progress)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, FileSize(1000), bytesRead)
|
assert.Equal(t, FileSize(1000), bytesRead)
|
||||||
@@ -320,15 +235,12 @@ func TestBuilderAddFileWithProgress(t *testing.T) {
|
|||||||
for p := range progress {
|
for p := range progress {
|
||||||
updates = append(updates, p)
|
updates = append(updates, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NotEmpty(t, updates)
|
assert.NotEmpty(t, updates)
|
||||||
// Last update should show all bytes
|
// Last update should show all bytes
|
||||||
assert.Equal(t, FileSize(1000), updates[len(updates)-1].BytesRead)
|
assert.Equal(t, FileSize(1000), updates[len(updates)-1].BytesRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderBuildRoundTrip(t *testing.T) {
|
func TestBuilderBuildRoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Build a manifest, deserialize it, verify all fields survive round-trip
|
// Build a manifest, deserialize it, verify all fields survive round-trip
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||||
@@ -344,14 +256,12 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
reader := bytes.NewReader(f.content)
|
reader := bytes.NewReader(f.content)
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile(RelFilePath(f.path), FileSize(len(f.content)), ModTime(now), reader, nil)
|
||||||
RelFilePath(f.path), FileSize(len(f.content)), ModTime(now), 0, reader, nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -360,104 +270,46 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
|
|||||||
require.Len(t, mfiles, 3)
|
require.Len(t, mfiles, 3)
|
||||||
|
|
||||||
// Verify sorted order
|
// Verify sorted order
|
||||||
assert.Equal(t, "alpha.txt", mfiles[0].GetPath())
|
assert.Equal(t, "alpha.txt", mfiles[0].Path)
|
||||||
assert.Equal(t, "beta/delta.txt", mfiles[1].GetPath())
|
assert.Equal(t, "beta/delta.txt", mfiles[1].Path)
|
||||||
assert.Equal(t, "beta/gamma.txt", mfiles[2].GetPath())
|
assert.Equal(t, "beta/gamma.txt", mfiles[2].Path)
|
||||||
|
|
||||||
// Verify sizes
|
// Verify sizes
|
||||||
assert.Equal(t, int64(len("alpha content")), mfiles[0].GetSize())
|
assert.Equal(t, int64(len("alpha content")), mfiles[0].Size)
|
||||||
|
|
||||||
// Verify hashes are present
|
// Verify hashes are present
|
||||||
for _, f := range mfiles {
|
for _, f := range mfiles {
|
||||||
require.NotEmpty(t, f.GetHashes(), "file %s should have hashes", f.GetPath())
|
require.NotEmpty(t, f.Hashes, "file %s should have hashes", f.Path)
|
||||||
assert.NotEmpty(t, f.GetHashes()[0].GetMultiHash())
|
assert.NotEmpty(t, f.Hashes[0].MultiHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A manifest whose payload is larger than one zstd block (128 KiB) is
|
|
||||||
// written as a frame asking for the writer's whole window, zstdWindowSize,
|
|
||||||
// which is the most the parser accepts.
|
|
||||||
func TestBuilderBuildRoundTripLargeManifest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
|
|
||||||
for i := range 4000 {
|
|
||||||
path := RelFilePath(fmt.Sprintf("dir/file-%05d.txt", i))
|
|
||||||
require.NoError(t, b.AddFileWithHash(path, FileSize(i), ModTime{}, 0, hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Greater(t, m.pbOuter.GetSize(), int64(128<<10))
|
|
||||||
assert.Len(t, m.Files(), 4000)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewManifestFromReaderInvalidMagic(t *testing.T) {
|
func TestNewManifestFromReaderInvalidMagic(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := NewManifestFromReader(bytes.NewReader([]byte("NOT_VALID")))
|
_, err := NewManifestFromReader(bytes.NewReader([]byte("NOT_VALID")))
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid file format")
|
assert.Contains(t, err.Error(), "invalid file format")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewManifestFromReaderEmpty(t *testing.T) {
|
func TestNewManifestFromReaderEmpty(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := NewManifestFromReader(bytes.NewReader([]byte{}))
|
_, err := NewManifestFromReader(bytes.NewReader([]byte{}))
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewManifestFromReaderTruncated(t *testing.T) {
|
func TestNewManifestFromReaderTruncated(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Just the magic with nothing after
|
// Just the magic with nothing after
|
||||||
_, err := NewManifestFromReader(bytes.NewReader([]byte(MAGIC)))
|
_, err := NewManifestFromReader(bytes.NewReader([]byte(MAGIC)))
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewManifestFromFileRequiresPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := NewManifestFromFile(nil)
|
|
||||||
require.ErrorIs(t, err, errManifestPathEmpty)
|
|
||||||
|
|
||||||
_, err = NewManifestFromFile(&ManifestFromFileOptions{Fs: afero.NewMemMapFs()})
|
|
||||||
require.ErrorIs(t, err, errManifestPathEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewManifestFromFileNilFsUsesOsFs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "index.mf")
|
|
||||||
createTestManifest(t, afero.NewOsFs(), path, map[string][]byte{
|
|
||||||
testFileName: []byte("hello"),
|
|
||||||
})
|
|
||||||
|
|
||||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{Path: path})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, m.Files(), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManifestString(t *testing.T) {
|
func TestManifestString(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := []byte("test")
|
content := []byte("test")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -465,13 +317,10 @@ func TestManifestString(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderBuildEmpty(t *testing.T) {
|
func TestBuilderBuildEmpty(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err := b.Build(&buf)
|
||||||
err := b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should still produce valid manifest with 0 files
|
// Should still produce valid manifest with 0 files
|
||||||
@@ -479,69 +328,48 @@ func TestBuilderBuildEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
|
func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
content := []byte("hello")
|
content := []byte("hello")
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), bytes.NewReader(content), nil)
|
||||||
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0,
|
|
||||||
bytes.NewReader(content), nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Nil(t, m.pbInner.GetCreatedAt(),
|
assert.Nil(t, m.pbInner.CreatedAt, "createdAt should be nil by default for deterministic output")
|
||||||
"createdAt should be nil by default for deterministic output")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
|
func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
b.SetIncludeTimestamps(true)
|
b.SetIncludeTimestamps(true)
|
||||||
|
|
||||||
content := []byte("hello")
|
content := []byte("hello")
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), bytes.NewReader(content), nil)
|
||||||
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0,
|
|
||||||
bytes.NewReader(content), nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, m.pbInner.GetCreatedAt(),
|
assert.NotNil(t, m.pbInner.CreatedAt, "createdAt should be set when IncludeTimestamps is true")
|
||||||
"createdAt should be set when IncludeTimestamps is true")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderDeterministicFileOrder(t *testing.T) {
|
func TestBuilderDeterministicFileOrder(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Two builds with same files in different order should produce same file ordering.
|
// Two builds with same files in different order should produce same file ordering.
|
||||||
// Note: UUIDs differ per build, so we compare parsed file lists, not raw bytes.
|
// Note: UUIDs differ per build, so we compare parsed file lists, not raw bytes.
|
||||||
buildAndParse := func(order []string) []*MFFilePath {
|
buildAndParse := func(order []string) []*MFFilePath {
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
|
|
||||||
for _, name := range order {
|
for _, name := range order {
|
||||||
content := []byte("content of " + name)
|
content := []byte("content of " + name)
|
||||||
_, err := b.AddFile(
|
_, err := b.AddFile(RelFilePath(name), FileSize(len(content)), ModTime(time.Unix(1000, 0)), bytes.NewReader(content), nil)
|
||||||
RelFilePath(name), FileSize(len(content)),
|
|
||||||
ModTime(time.Unix(1000, 0)), 0, bytes.NewReader(content), nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return m.Files()
|
return m.Files()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,12 +378,10 @@ func TestBuilderDeterministicFileOrder(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, files1, 2)
|
require.Len(t, files1, 2)
|
||||||
require.Len(t, files2, 2)
|
require.Len(t, files2, 2)
|
||||||
|
|
||||||
for i := range files1 {
|
for i := range files1 {
|
||||||
assert.Equal(t, files1[i].GetPath(), files2[i].GetPath())
|
assert.Equal(t, files1[i].Path, files2[i].Path)
|
||||||
assert.Equal(t, files1[i].GetSize(), files2[i].GetSize())
|
assert.Equal(t, files1[i].Size, files2[i].Size)
|
||||||
}
|
}
|
||||||
|
assert.Equal(t, "a.txt", files1[0].Path)
|
||||||
assert.Equal(t, "a.txt", files1[0].GetPath())
|
assert.Equal(t, "b.txt", files1[1].Path)
|
||||||
assert.Equal(t, "b.txt", files1[1].GetPath())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-200
@@ -14,12 +14,6 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
errNoSigningPubKey = errors.New("manifest has no signing public key")
|
|
||||||
errManifestPathEmpty = errors.New("manifest path cannot be empty")
|
|
||||||
errBasePathEmpty = errors.New("base path cannot be empty")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Result represents the outcome of checking a single file.
|
// Result represents the outcome of checking a single file.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Path RelFilePath // Relative path from manifest
|
Path RelFilePath // Relative path from manifest
|
||||||
@@ -30,13 +24,11 @@ type Result struct {
|
|||||||
// Status represents the verification status of a file.
|
// Status represents the verification status of a file.
|
||||||
type Status int
|
type Status int
|
||||||
|
|
||||||
// Verification result statuses reported for each checked file.
|
|
||||||
const (
|
const (
|
||||||
StatusOK Status = iota // File matches manifest (size and hash verified)
|
StatusOK Status = iota // File matches manifest (size and hash verified)
|
||||||
StatusMissing // File not found on disk
|
StatusMissing // File not found on disk
|
||||||
StatusSizeMismatch // File size differs from manifest
|
StatusSizeMismatch // File size differs from manifest
|
||||||
StatusHashMismatch // File hash differs from manifest
|
StatusHashMismatch // File hash differs from manifest
|
||||||
StatusModeMismatch // File permission bits differ from a recorded mode
|
|
||||||
StatusExtra // File exists on disk but not in manifest
|
StatusExtra // File exists on disk but not in manifest
|
||||||
StatusError // Error occurred during verification
|
StatusError // Error occurred during verification
|
||||||
)
|
)
|
||||||
@@ -51,8 +43,6 @@ func (s Status) String() string {
|
|||||||
return "SIZE_MISMATCH"
|
return "SIZE_MISMATCH"
|
||||||
case StatusHashMismatch:
|
case StatusHashMismatch:
|
||||||
return "HASH_MISMATCH"
|
return "HASH_MISMATCH"
|
||||||
case StatusModeMismatch:
|
|
||||||
return "MODE_MISMATCH"
|
|
||||||
case StatusExtra:
|
case StatusExtra:
|
||||||
return "EXTRA"
|
return "EXTRA"
|
||||||
case StatusError:
|
case StatusError:
|
||||||
@@ -80,76 +70,57 @@ type Checker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
// manifestPaths is a set of paths in the manifest for quick lookup
|
// manifestPaths is a set of paths in the manifest for quick lookup
|
||||||
manifestPaths map[RelFilePath]struct{}
|
manifestPaths map[RelFilePath]struct{}
|
||||||
// manifestInfo is the manifest file, which FindExtraFiles leaves out,
|
// manifestRelPath is the relative path of the manifest file from basePath (for exclusion)
|
||||||
// matched with os.SameFile.
|
manifestRelPath RelFilePath
|
||||||
manifestInfo os.FileInfo
|
|
||||||
// signature info from the manifest
|
// signature info from the manifest
|
||||||
signature []byte
|
signature []byte
|
||||||
signer []byte
|
signer []byte
|
||||||
signingPubKey []byte
|
signingPubKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckerOptions configures a Checker.
|
// NewChecker creates a new Checker for the given manifest, base path, and filesystem.
|
||||||
type CheckerOptions struct {
|
// The basePath is the directory relative to which manifest paths are resolved.
|
||||||
// ManifestPath is the manifest file to check against (required).
|
// If fs is nil, the real filesystem (OsFs) is used.
|
||||||
ManifestPath string
|
func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, error) {
|
||||||
// BasePath is the directory relative to which manifest paths are
|
|
||||||
// resolved (required).
|
|
||||||
BasePath string
|
|
||||||
// Fs is the filesystem to use, defaults to OsFs if nil.
|
|
||||||
Fs afero.Fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewChecker creates a new Checker with the given options. It returns an
|
|
||||||
// error if opts is nil or either path is empty.
|
|
||||||
func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
|
||||||
if opts == nil || opts.ManifestPath == "" {
|
|
||||||
return nil, errManifestPathEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.BasePath == "" {
|
|
||||||
return nil, errBasePathEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := opts.Fs
|
|
||||||
if fs == nil {
|
if fs == nil {
|
||||||
fs = afero.NewOsFs()
|
fs = afero.NewOsFs()
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{
|
m, err := NewManifestFromFile(fs, manifestPath)
|
||||||
Path: opts.ManifestPath,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
abs, err := filepath.Abs(opts.BasePath)
|
abs, err := filepath.Abs(basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
files := m.Files()
|
files := m.Files()
|
||||||
|
|
||||||
manifestPaths := make(map[RelFilePath]struct{}, len(files))
|
manifestPaths := make(map[RelFilePath]struct{}, len(files))
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
|
manifestPaths[RelFilePath(f.Path)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
manifestInfo, err := fs.Stat(opts.ManifestPath)
|
// Compute manifest's relative path from basePath for exclusion in FindExtraFiles
|
||||||
|
absManifest, err := filepath.Abs(manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
manifestRel, err := filepath.Rel(abs, absManifest)
|
||||||
|
if err != nil {
|
||||||
|
manifestRel = ""
|
||||||
|
}
|
||||||
|
|
||||||
return &Checker{
|
return &Checker{
|
||||||
basePath: AbsFilePath(abs),
|
basePath: AbsFilePath(abs),
|
||||||
files: files,
|
files: files,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
manifestPaths: manifestPaths,
|
manifestPaths: manifestPaths,
|
||||||
manifestInfo: manifestInfo,
|
manifestRelPath: RelFilePath(manifestRel),
|
||||||
signature: m.pbOuter.GetSignature(),
|
signature: m.pbOuter.Signature,
|
||||||
signer: m.pbOuter.GetSigner(),
|
signer: m.pbOuter.Signer,
|
||||||
signingPubKey: m.pbOuter.GetSigningPubKey(),
|
signingPubKey: m.pbOuter.SigningPubKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,9 +133,8 @@ func (c *Checker) FileCount() FileCount {
|
|||||||
func (c *Checker) TotalBytes() FileSize {
|
func (c *Checker) TotalBytes() FileSize {
|
||||||
var total FileSize
|
var total FileSize
|
||||||
for _, f := range c.files {
|
for _, f := range c.files {
|
||||||
total += FileSize(f.GetSize())
|
total += FileSize(f.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +148,7 @@ func (c *Checker) Signer() []byte {
|
|||||||
return c.signer
|
return c.signer
|
||||||
}
|
}
|
||||||
|
|
||||||
// SigningPubKey returns the signing public key if the manifest is signed,
|
// SigningPubKey returns the signing public key if the manifest is signed, nil otherwise.
|
||||||
// nil otherwise.
|
|
||||||
func (c *Checker) SigningPubKey() []byte {
|
func (c *Checker) SigningPubKey() []byte {
|
||||||
return c.signingPubKey
|
return c.signingPubKey
|
||||||
}
|
}
|
||||||
@@ -187,27 +156,21 @@ func (c *Checker) SigningPubKey() []byte {
|
|||||||
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
||||||
// temporary keyring and extracts its fingerprint. This validates the key and
|
// temporary keyring and extracts its fingerprint. This validates the key and
|
||||||
// returns its actual fingerprint from the key material itself.
|
// returns its actual fingerprint from the key material itself.
|
||||||
func (c *Checker) ExtractEmbeddedSigningKeyFP(ctx context.Context) (string, error) {
|
func (c *Checker) ExtractEmbeddedSigningKeyFP() (string, error) {
|
||||||
if len(c.signingPubKey) == 0 {
|
if len(c.signingPubKey) == 0 {
|
||||||
return "", errNoSigningPubKey
|
return "", errors.New("manifest has no signing public key")
|
||||||
}
|
}
|
||||||
|
return gpgExtractPubKeyFingerprint(c.signingPubKey)
|
||||||
return gpgExtractPubKeyFingerprint(ctx, c.signingPubKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check verifies all files against the manifest.
|
// Check verifies all files against the manifest.
|
||||||
// Results are sent to the results channel as files are checked.
|
// Results are sent to the results channel as files are checked.
|
||||||
// Progress updates are sent to the progress channel approximately once per second.
|
// Progress updates are sent to the progress channel approximately once per second.
|
||||||
// Both channels are closed when the method returns.
|
// Both channels are closed when the method returns.
|
||||||
func (c *Checker) Check(
|
func (c *Checker) Check(ctx context.Context, results chan<- Result, progress chan<- CheckStatus) error {
|
||||||
ctx context.Context,
|
|
||||||
results chan<- Result,
|
|
||||||
progress chan<- CheckStatus,
|
|
||||||
) error {
|
|
||||||
if results != nil {
|
if results != nil {
|
||||||
defer close(results)
|
defer close(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
defer close(progress)
|
defer close(progress)
|
||||||
}
|
}
|
||||||
@@ -215,11 +178,9 @@ func (c *Checker) Check(
|
|||||||
totalFiles := FileCount(len(c.files))
|
totalFiles := FileCount(len(c.files))
|
||||||
totalBytes := c.TotalBytes()
|
totalBytes := c.TotalBytes()
|
||||||
|
|
||||||
var (
|
var checkedFiles FileCount
|
||||||
checkedFiles FileCount
|
var checkedBytes FileSize
|
||||||
checkedBytes FileSize
|
var failures FileCount
|
||||||
failures FileCount
|
|
||||||
)
|
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
lastProgressTime := time.Now()
|
lastProgressTime := time.Now()
|
||||||
@@ -235,7 +196,6 @@ func (c *Checker) Check(
|
|||||||
if result.Status != StatusOK {
|
if result.Status != StatusOK {
|
||||||
failures++
|
failures++
|
||||||
}
|
}
|
||||||
|
|
||||||
checkedFiles++
|
checkedFiles++
|
||||||
|
|
||||||
if results != nil {
|
if results != nil {
|
||||||
@@ -245,12 +205,19 @@ func (c *Checker) Check(
|
|||||||
// Send progress at most once per second (rate-limited)
|
// Send progress at most once per second (rate-limited)
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
isLast := checkedFiles == totalFiles
|
isLast := checkedFiles == totalFiles
|
||||||
if isLast || now.Sub(lastProgressTime) >= time.Second {
|
if isLast || now.Sub(lastProgressTime) >= time.Second {
|
||||||
bytesPerSec, eta := computeRateETA(
|
elapsed := time.Since(startTime)
|
||||||
time.Since(startTime), checkedBytes, totalBytes,
|
var bytesPerSec float64
|
||||||
)
|
var eta time.Duration
|
||||||
|
|
||||||
|
if elapsed > 0 && checkedBytes > 0 {
|
||||||
|
bytesPerSec = float64(checkedBytes) / elapsed.Seconds()
|
||||||
|
remainingBytes := totalBytes - checkedBytes
|
||||||
|
if bytesPerSec > 0 {
|
||||||
|
eta = time.Duration(float64(remainingBytes)/bytesPerSec) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendCheckStatus(progress, CheckStatus{
|
sendCheckStatus(progress, CheckStatus{
|
||||||
TotalFiles: totalFiles,
|
TotalFiles: totalFiles,
|
||||||
@@ -261,7 +228,6 @@ func (c *Checker) Check(
|
|||||||
ETA: eta,
|
ETA: eta,
|
||||||
Failures: failures,
|
Failures: failures,
|
||||||
})
|
})
|
||||||
|
|
||||||
lastProgressTime = now
|
lastProgressTime = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,27 +236,73 @@ func (c *Checker) Check(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindExtraFiles walks the filesystem and reports files not in the manifest,
|
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
||||||
// hidden files and directories included. The manifest file itself is not
|
absPath := filepath.Join(string(c.basePath), entry.Path)
|
||||||
// reported. Anything the search cannot read, such as a directory that cannot
|
relPath := RelFilePath(entry.Path)
|
||||||
// be listed, is reported with StatusError and the search goes on. Results are
|
|
||||||
// sent to the results channel. The channel is closed when done.
|
// Check if file exists
|
||||||
|
info, err := c.fs.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) || errors.Is(err, afero.ErrFileNotFound) {
|
||||||
|
return Result{Path: relPath, Status: StatusMissing, Message: "file not found"}
|
||||||
|
}
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check size
|
||||||
|
if info.Size() != entry.Size {
|
||||||
|
*checkedBytes += FileSize(info.Size())
|
||||||
|
return Result{
|
||||||
|
Path: relPath,
|
||||||
|
Status: StatusSizeMismatch,
|
||||||
|
Message: "size mismatch",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open and hash file
|
||||||
|
f, err := c.fs.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
n, err := io.Copy(h, f)
|
||||||
|
if err != nil {
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
*checkedBytes += FileSize(n)
|
||||||
|
|
||||||
|
// Encode as multihash and compare
|
||||||
|
computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||||
|
if err != nil {
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against all hashes in manifest (at least one must match)
|
||||||
|
for _, hash := range entry.Hashes {
|
||||||
|
if bytes.Equal(computed, hash.MultiHash) {
|
||||||
|
return Result{Path: relPath, Status: StatusOK}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{Path: relPath, Status: StatusHashMismatch, Message: "hash mismatch"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindExtraFiles walks the filesystem and reports files not in the manifest.
|
||||||
|
// Results are sent to the results channel. The channel is closed when done.
|
||||||
|
// Hidden files/directories (starting with .) are skipped, as they are excluded
|
||||||
|
// from manifests by default. The manifest file itself is also skipped.
|
||||||
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
||||||
if results != nil {
|
if results != nil {
|
||||||
defer close(results)
|
defer close(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The search does not follow symlinks, so a base directory named
|
return afero.Walk(c.fs, string(c.basePath), func(walkPath string, info os.FileInfo, err error) error {
|
||||||
// through one is resolved first. If that fails, the base is searched as
|
if err != nil {
|
||||||
// named and the search reports the problem.
|
return err
|
||||||
root := string(c.basePath)
|
}
|
||||||
|
|
||||||
resolved, err := filepath.EvalSymlinks(root)
|
|
||||||
if err == nil {
|
|
||||||
root = resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
walkFn := func(walkPath string, info os.FileInfo, walkErr error) error {
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -298,24 +310,16 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get relative path
|
// Get relative path
|
||||||
rel, err := filepath.Rel(root, walkPath)
|
rel, err := filepath.Rel(string(c.basePath), walkPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath := RelFilePath(rel)
|
// Skip hidden files and directories (dotfiles)
|
||||||
|
if IsHiddenPath(filepath.ToSlash(rel)) {
|
||||||
// Report what cannot be read, such as a directory that cannot be
|
if info.IsDir() {
|
||||||
// listed, and go on with the rest.
|
return filepath.SkipDir
|
||||||
if walkErr != nil {
|
|
||||||
if results != nil {
|
|
||||||
results <- Result{
|
|
||||||
Path: relPath,
|
|
||||||
Status: StatusError,
|
|
||||||
Message: walkErr.Error(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,17 +328,10 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// A symlink is compared by what it points to, so a manifest reached
|
relPath := RelFilePath(rel)
|
||||||
// through one is not reported either.
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
target, statErr := c.fs.Stat(walkPath)
|
|
||||||
if statErr == nil {
|
|
||||||
info = target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip the manifest file itself, however its path is spelled
|
// Skip the manifest file itself
|
||||||
if os.SameFile(info, c.manifestInfo) {
|
if relPath == c.manifestRelPath {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,90 +347,7 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
})
|
||||||
|
|
||||||
return afero.Walk(c.fs, root, walkFn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
|
||||||
// entry.GetPath() is safe to join here: a manifest's entry paths are
|
|
||||||
// validated against the path invariants when it is loaded (see
|
|
||||||
// deserializeInner) or built (see Builder.AddFile), so a traversal or
|
|
||||||
// absolute path can never reach this point.
|
|
||||||
absPath := filepath.Join(string(c.basePath), entry.GetPath())
|
|
||||||
relPath := RelFilePath(entry.GetPath())
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
info, err := c.fs.Stat(absPath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, afero.ErrFileNotFound) {
|
|
||||||
return Result{
|
|
||||||
Path: relPath,
|
|
||||||
Status: StatusMissing,
|
|
||||||
Message: "file not found",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check size
|
|
||||||
if info.Size() != entry.GetSize() {
|
|
||||||
*checkedBytes += FileSize(info.Size())
|
|
||||||
|
|
||||||
return Result{
|
|
||||||
Path: relPath,
|
|
||||||
Status: StatusSizeMismatch,
|
|
||||||
Message: "size mismatch",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open and hash file
|
|
||||||
f, err := c.fs.Open(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
|
||||||
|
|
||||||
h := sha256.New()
|
|
||||||
|
|
||||||
n, err := io.Copy(h, f)
|
|
||||||
if err != nil {
|
|
||||||
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
*checkedBytes += FileSize(n)
|
|
||||||
|
|
||||||
// Encode as multihash and compare
|
|
||||||
computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
|
||||||
if err != nil {
|
|
||||||
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check against all hashes in manifest (at least one must match),
|
|
||||||
// then against the recorded mode, where one is: 0 means none was.
|
|
||||||
for _, hash := range entry.GetHashes() {
|
|
||||||
if !bytes.Equal(computed, hash.GetMultiHash()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.GetMode() != 0 && info.Mode().Perm() != os.FileMode(entry.GetMode()) {
|
|
||||||
return Result{
|
|
||||||
Path: relPath,
|
|
||||||
Status: StatusModeMismatch,
|
|
||||||
Message: "mode mismatch",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result{Path: relPath, Status: StatusOK}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result{
|
|
||||||
Path: relPath,
|
|
||||||
Status: StatusHashMismatch,
|
|
||||||
Message: "hash mismatch",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendCheckStatus sends a status update without blocking.
|
// sendCheckStatus sends a status update without blocking.
|
||||||
@@ -441,7 +355,6 @@ func sendCheckStatus(ch chan<- CheckStatus, status CheckStatus) {
|
|||||||
if ch == nil {
|
if ch == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case ch <- status:
|
case ch <- status:
|
||||||
default:
|
default:
|
||||||
|
|||||||
+168
-442
@@ -1,12 +1,9 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,17 +12,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
testFile1 = "file1.txt"
|
|
||||||
testFile2 = "file2.txt"
|
|
||||||
testExistsFile = "exists.txt"
|
|
||||||
testManifestPath = "/manifest.mf"
|
|
||||||
testDataDir = "/data"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStatusString(t *testing.T) {
|
func TestStatusString(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
status Status
|
status Status
|
||||||
expected string
|
expected string
|
||||||
@@ -34,7 +21,6 @@ func TestStatusString(t *testing.T) {
|
|||||||
{StatusMissing, "MISSING"},
|
{StatusMissing, "MISSING"},
|
||||||
{StatusSizeMismatch, "SIZE_MISMATCH"},
|
{StatusSizeMismatch, "SIZE_MISMATCH"},
|
||||||
{StatusHashMismatch, "HASH_MISMATCH"},
|
{StatusHashMismatch, "HASH_MISMATCH"},
|
||||||
{StatusModeMismatch, "MODE_MISMATCH"},
|
|
||||||
{StatusExtra, "EXTRA"},
|
{StatusExtra, "EXTRA"},
|
||||||
{StatusError, "ERROR"},
|
{StatusError, "ERROR"},
|
||||||
{Status(99), "UNKNOWN"},
|
{Status(99), "UNKNOWN"},
|
||||||
@@ -42,169 +28,77 @@ func TestStatusString(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.expected, func(t *testing.T) {
|
t.Run(tt.expected, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, tt.status.String())
|
assert.Equal(t, tt.expected, tt.status.String())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTestManifest creates a manifest file in the filesystem with the given files.
|
// createTestManifest creates a manifest file in the filesystem with the given files.
|
||||||
func createTestManifest(
|
func createTestManifest(t *testing.T, fs afero.Fs, manifestPath string, files map[string][]byte) {
|
||||||
t *testing.T, fs afero.Fs, manifestPath string, files map[string][]byte,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
builder := NewBuilder()
|
builder := NewBuilder()
|
||||||
|
|
||||||
for path, content := range files {
|
for path, content := range files {
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
_, err := builder.AddFile(
|
_, err := builder.AddFile(RelFilePath(path), FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
RelFilePath(path), FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, builder.Build(context.Background(), &buf))
|
require.NoError(t, builder.Build(&buf))
|
||||||
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
// createFilesOnDisk creates the given files on the filesystem under
|
// createFilesOnDisk creates the given files on the filesystem.
|
||||||
// testDataDir.
|
func createFilesOnDisk(t *testing.T, fs afero.Fs, basePath string, files map[string][]byte) {
|
||||||
func createFilesOnDisk(t *testing.T, fs afero.Fs, files map[string][]byte) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
for path, content := range files {
|
for path, content := range files {
|
||||||
fullPath := testDataDir + "/" + path
|
fullPath := basePath + "/" + path
|
||||||
require.NoError(t, fs.MkdirAll(testDataDir, 0o755))
|
require.NoError(t, fs.MkdirAll(basePath, 0o755))
|
||||||
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewChecker(t *testing.T) {
|
func TestNewChecker(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("valid manifest", func(t *testing.T) {
|
t.Run("valid manifest", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFile1: []byte("hello"),
|
"file1.txt": []byte("hello"),
|
||||||
testFile2: []byte("world"),
|
"file2.txt": []byte("world"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: "/",
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, chk)
|
assert.NotNil(t, chk)
|
||||||
assert.Equal(t, FileCount(2), chk.FileCount())
|
assert.Equal(t, FileCount(2), chk.FileCount())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing manifest", func(t *testing.T) {
|
t.Run("missing manifest", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := NewChecker(&CheckerOptions{
|
_, err := NewChecker("/nonexistent.mf", "/", fs)
|
||||||
ManifestPath: "/nonexistent.mf",
|
|
||||||
BasePath: "/",
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid manifest", func(t *testing.T) {
|
t.Run("invalid manifest", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
|
||||||
_, err := NewChecker(&CheckerOptions{
|
_, err := NewChecker("/bad.mf", "/", fs)
|
||||||
ManifestPath: "/bad.mf",
|
|
||||||
BasePath: "/",
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewCheckerRequiredPaths(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
opts *CheckerOptions
|
|
||||||
want string
|
|
||||||
is error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil options",
|
|
||||||
opts: nil,
|
|
||||||
want: "manifest path cannot be empty",
|
|
||||||
is: errManifestPathEmpty,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty manifest path",
|
|
||||||
opts: &CheckerOptions{BasePath: testDataDir},
|
|
||||||
want: "manifest path cannot be empty",
|
|
||||||
is: errManifestPathEmpty,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty base path",
|
|
||||||
opts: &CheckerOptions{ManifestPath: testManifestPath},
|
|
||||||
want: "base path cannot be empty",
|
|
||||||
is: errBasePathEmpty,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
chk, err := NewChecker(tc.opts)
|
|
||||||
require.ErrorIs(t, err, tc.is)
|
|
||||||
require.EqualError(t, err, tc.want)
|
|
||||||
assert.Nil(t, chk)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCheckerNilFsUsesOsFs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
manifestPath := filepath.Join(dir, "index.mf")
|
|
||||||
content := []byte("hello")
|
|
||||||
createTestManifest(t, afero.NewOsFs(), manifestPath, map[string][]byte{
|
|
||||||
testFile1: content,
|
|
||||||
})
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, testFile1), content, 0o600))
|
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{ManifestPath: manifestPath, BasePath: dir})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
results := make(chan Result, 1)
|
|
||||||
require.NoError(t, chk.Check(context.Background(), results, nil))
|
|
||||||
assert.Equal(t, StatusOK, (<-results).Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
"small.txt": []byte("hi"),
|
"small.txt": []byte("hi"),
|
||||||
"medium.txt": []byte("hello world"),
|
"medium.txt": []byte("hello world"),
|
||||||
"large.txt": bytes.Repeat([]byte("x"), 1000),
|
"large.txt": bytes.Repeat([]byte("x"), 1000),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: "/",
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, FileCount(3), chk.FileCount())
|
assert.Equal(t, FileCount(3), chk.FileCount())
|
||||||
@@ -212,21 +106,15 @@ func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckAllFilesOK(t *testing.T) {
|
func TestCheckAllFilesOK(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFile1: []byte("content one"),
|
"file1.txt": []byte("content one"),
|
||||||
testFile2: []byte("content two"),
|
"file2.txt": []byte("content two"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -239,31 +127,24 @@ func TestCheckAllFilesOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(t, resultList, 2)
|
assert.Len(t, resultList, 2)
|
||||||
|
|
||||||
for _, r := range resultList {
|
for _, r := range resultList {
|
||||||
assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path)
|
assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckMissingFile(t *testing.T) {
|
func TestCheckMissingFile(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testExistsFile: []byte("I exist"),
|
"exists.txt": []byte("I exist"),
|
||||||
"missing.txt": []byte("I don't exist on disk"),
|
"missing.txt": []byte("I don't exist on disk"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
// Only create one file
|
// Only create one file
|
||||||
createFilesOnDisk(t, fs, map[string][]byte{
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
testExistsFile: []byte("I exist"),
|
"exists.txt": []byte("I exist"),
|
||||||
})
|
})
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -271,18 +152,13 @@ func TestCheckMissingFile(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var okCount, missingCount int
|
var okCount, missingCount int
|
||||||
|
|
||||||
for r := range results {
|
for r := range results {
|
||||||
switch r.Status {
|
switch r.Status {
|
||||||
case StatusOK:
|
case StatusOK:
|
||||||
okCount++
|
okCount++
|
||||||
case StatusMissing:
|
case StatusMissing:
|
||||||
missingCount++
|
missingCount++
|
||||||
|
|
||||||
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
|
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
|
||||||
case StatusSizeMismatch, StatusHashMismatch, StatusModeMismatch,
|
|
||||||
StatusExtra, StatusError:
|
|
||||||
// Not expected in this test; counted assertions below will fail.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,23 +167,17 @@ func TestCheckMissingFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckSizeMismatch(t *testing.T) {
|
func TestCheckSizeMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFileName: []byte("original content"),
|
"file.txt": []byte("original content"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
// Create file with different size
|
// Create file with different size
|
||||||
createFilesOnDisk(t, fs, map[string][]byte{
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
testFileName: []byte("short"),
|
"file.txt": []byte("short"),
|
||||||
})
|
})
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -316,30 +186,24 @@ func TestCheckSizeMismatch(t *testing.T) {
|
|||||||
|
|
||||||
r := <-results
|
r := <-results
|
||||||
assert.Equal(t, StatusSizeMismatch, r.Status)
|
assert.Equal(t, StatusSizeMismatch, r.Status)
|
||||||
assert.Equal(t, RelFilePath(testFileName), r.Path)
|
assert.Equal(t, RelFilePath("file.txt"), r.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckHashMismatch(t *testing.T) {
|
func TestCheckHashMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
originalContent := []byte("original content")
|
originalContent := []byte("original content")
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFileName: originalContent,
|
"file.txt": originalContent,
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
// Create file with same size but different content
|
// Create file with same size but different content
|
||||||
differentContent := []byte("different contnt") // same length (16 bytes) but different
|
differentContent := []byte("different contnt") // same length (16 bytes) but different
|
||||||
require.Len(t, differentContent, len(originalContent), "test requires same length")
|
require.Equal(t, len(originalContent), len(differentContent), "test requires same length")
|
||||||
createFilesOnDisk(t, fs, map[string][]byte{
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
testFileName: differentContent,
|
"file.txt": differentContent,
|
||||||
})
|
})
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -348,73 +212,19 @@ func TestCheckHashMismatch(t *testing.T) {
|
|||||||
|
|
||||||
r := <-results
|
r := <-results
|
||||||
assert.Equal(t, StatusHashMismatch, r.Status)
|
assert.Equal(t, StatusHashMismatch, r.Status)
|
||||||
assert.Equal(t, RelFilePath(testFileName), r.Path)
|
assert.Equal(t, RelFilePath("file.txt"), r.Path)
|
||||||
}
|
|
||||||
|
|
||||||
// A recorded mode other than 0000 that differs from the file's permission
|
|
||||||
// bits fails the check; a recorded 0000 is never checked.
|
|
||||||
func TestCheckMode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
recorded os.FileMode
|
|
||||||
onDisk os.FileMode
|
|
||||||
want Status
|
|
||||||
}{
|
|
||||||
{"recorded mode matches", 0o640, 0o640, StatusOK},
|
|
||||||
{"recorded mode differs", 0o640, 0o600, StatusModeMismatch},
|
|
||||||
{"0000 is not checked", 0, 0o600, StatusOK},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
content := []byte("content")
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
_, err := b.AddFile(testFileName, FileSize(len(content)), ModTime{},
|
|
||||||
tc.recorded, bytes.NewReader(content), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testManifestPath, buf.Bytes(), 0o644))
|
|
||||||
require.NoError(t, fs.MkdirAll(testDataDir, 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
testDataDir+"/"+testFileName, content, tc.onDisk))
|
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
results := make(chan Result, 1)
|
|
||||||
require.NoError(t, chk.Check(context.Background(), results, nil))
|
|
||||||
|
|
||||||
assert.Equal(t, tc.want, (<-results).Status)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckWithProgress(t *testing.T) {
|
func TestCheckWithProgress(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFile1: bytes.Repeat([]byte("a"), 100),
|
"file1.txt": bytes.Repeat([]byte("a"), 100),
|
||||||
testFile2: bytes.Repeat([]byte("b"), 200),
|
"file2.txt": bytes.Repeat([]byte("b"), 200),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -423,7 +233,9 @@ func TestCheckWithProgress(t *testing.T) {
|
|||||||
err = chk.Check(context.Background(), results, progress)
|
err = chk.Check(context.Background(), results, progress)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// results is fully buffered and closed; no draining needed
|
// Drain results
|
||||||
|
for range results {
|
||||||
|
}
|
||||||
|
|
||||||
// Check progress was sent
|
// Check progress was sent
|
||||||
var progressUpdates []CheckStatus
|
var progressUpdates []CheckStatus
|
||||||
@@ -442,23 +254,16 @@ func TestCheckWithProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckContextCancellation(t *testing.T) {
|
func TestCheckContextCancellation(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
// Create many files to ensure we have time to cancel
|
// Create many files to ensure we have time to cancel
|
||||||
files := make(map[string][]byte)
|
files := make(map[string][]byte)
|
||||||
for i := range 100 {
|
for i := 0; i < 100; i++ {
|
||||||
files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000)
|
files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000)
|
||||||
}
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
createFilesOnDisk(t, fs, files)
|
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -470,26 +275,20 @@ func TestCheckContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFindExtraFiles(t *testing.T) {
|
func TestFindExtraFiles(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
// Manifest only contains file1
|
// Manifest only contains file1
|
||||||
manifestFiles := map[string][]byte{
|
manifestFiles := map[string][]byte{
|
||||||
testFile1: []byte("in manifest"),
|
"file1.txt": []byte("in manifest"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, manifestFiles)
|
createTestManifest(t, fs, "/manifest.mf", manifestFiles)
|
||||||
|
|
||||||
// Disk has file1 and file2
|
// Disk has file1 and file2
|
||||||
createFilesOnDisk(t, fs, map[string][]byte{
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
testFile1: []byte("in manifest"),
|
"file1.txt": []byte("in manifest"),
|
||||||
testFile2: []byte("extra file"),
|
"file2.txt": []byte("extra file"),
|
||||||
})
|
})
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -502,140 +301,56 @@ func TestFindExtraFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(t, extras, 1)
|
assert.Len(t, extras, 1)
|
||||||
assert.Equal(t, RelFilePath(testFile2), extras[0].Path)
|
assert.Equal(t, RelFilePath("file2.txt"), extras[0].Path)
|
||||||
assert.Equal(t, StatusExtra, extras[0].Status)
|
assert.Equal(t, StatusExtra, extras[0].Status)
|
||||||
assert.Equal(t, "not in manifest", extras[0].Message)
|
assert.Equal(t, "not in manifest", extras[0].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFindExtraFilesReportsHiddenFilesButNotManifest keeps the manifest
|
func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
||||||
// inside the checked tree: hidden files and directories are reported, the
|
fs := afero.NewMemMapFs()
|
||||||
// manifest is not. The manifest is recognized by file identity, which needs
|
manifestFiles := map[string][]byte{
|
||||||
// the real filesystem.
|
"file1.txt": []byte("in manifest"),
|
||||||
func TestFindExtraFilesReportsHiddenFilesButNotManifest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
manifestPath := filepath.Join(dir, "index.mf")
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
createTestManifest(t, fs, manifestPath, map[string][]byte{
|
|
||||||
testFile1: []byte("in manifest"),
|
|
||||||
})
|
|
||||||
|
|
||||||
unlisted := []RelFilePath{"extra.txt", ".hidden", ".git/config"}
|
|
||||||
for _, p := range append([]RelFilePath{testFile1}, unlisted...) {
|
|
||||||
path := filepath.Join(dir, string(p))
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o750))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, path, []byte("x"), 0o600))
|
|
||||||
}
|
}
|
||||||
|
createTestManifest(t, fs, "/data/.index.mf", manifestFiles)
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
ManifestPath: manifestPath,
|
"file1.txt": []byte("in manifest"),
|
||||||
BasePath: dir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
})
|
||||||
|
// Create dotfile and manifest that should be skipped
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("hidden"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.config/settings", []byte("cfg"), 0o644))
|
||||||
|
// Create a real extra file
|
||||||
|
require.NoError(t, fs.MkdirAll("/data", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
|
||||||
|
|
||||||
|
chk, err := NewChecker("/data/.index.mf", "/data", fs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
|
||||||
var extras []RelFilePath
|
|
||||||
for r := range results {
|
|
||||||
extras = append(extras, r.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, unlisted, extras)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFindExtraFilesSkipsManifestReachedThroughSymlink checks a tree whose
|
|
||||||
// index.mf is a symlink to the manifest kept outside the tree: the symlink is
|
|
||||||
// not reported.
|
|
||||||
func TestFindExtraFilesSkipsManifestReachedThroughSymlink(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
tree := filepath.Join(dir, "tree")
|
|
||||||
manifestPath := filepath.Join(dir, "real.mf")
|
|
||||||
linkPath := filepath.Join(tree, "index.mf")
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
createTestManifest(t, fs, manifestPath, map[string][]byte{testFile1: []byte("x")})
|
|
||||||
require.NoError(t, fs.MkdirAll(tree, 0o750))
|
|
||||||
require.NoError(t,
|
|
||||||
afero.WriteFile(fs, filepath.Join(tree, testFile1), []byte("x"), 0o600))
|
|
||||||
require.NoError(t, os.Symlink(manifestPath, linkPath))
|
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
|
||||||
ManifestPath: linkPath,
|
|
||||||
BasePath: tree,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
var extras []Result
|
||||||
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
|
||||||
|
|
||||||
var extras []RelFilePath
|
|
||||||
for r := range results {
|
for r := range results {
|
||||||
extras = append(extras, r.Path)
|
extras = append(extras, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Empty(t, extras)
|
// Should only report extra.txt, not .hidden, .config/settings, or .index.mf
|
||||||
}
|
for _, e := range extras {
|
||||||
|
t.Logf("extra: %s", e.Path)
|
||||||
// TestFindExtraFilesSearchesBaseNamedThroughSymlink names the checked tree
|
|
||||||
// through a symlink to it: the files in the tree are searched, and the
|
|
||||||
// symlink itself is not reported.
|
|
||||||
func TestFindExtraFilesSearchesBaseNamedThroughSymlink(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
tree := filepath.Join(dir, "tree")
|
|
||||||
link := filepath.Join(dir, "link")
|
|
||||||
manifestPath := filepath.Join(dir, "index.mf")
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
createTestManifest(t, fs, manifestPath, map[string][]byte{testFile1: []byte("x")})
|
|
||||||
require.NoError(t, fs.MkdirAll(tree, 0o750))
|
|
||||||
|
|
||||||
for _, name := range []string{testFile1, testFile2} {
|
|
||||||
require.NoError(t,
|
|
||||||
afero.WriteFile(fs, filepath.Join(tree, name), []byte("x"), 0o600))
|
|
||||||
}
|
}
|
||||||
|
assert.Len(t, extras, 1)
|
||||||
require.NoError(t, os.Symlink(tree, link))
|
if len(extras) > 0 {
|
||||||
|
assert.Equal(t, RelFilePath("extra.txt"), extras[0].Path)
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
|
||||||
ManifestPath: manifestPath,
|
|
||||||
BasePath: link,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
|
||||||
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
|
||||||
|
|
||||||
var extras []RelFilePath
|
|
||||||
for r := range results {
|
|
||||||
extras = append(extras, r.Path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, []RelFilePath{testFile2}, extras)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindExtraFilesContextCancellation(t *testing.T) {
|
func TestFindExtraFilesContextCancellation(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{testFileName: []byte("data")}
|
files := map[string][]byte{"file.txt": []byte("data")}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -647,18 +362,12 @@ func TestFindExtraFilesContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckNilChannels(t *testing.T) {
|
func TestCheckNilChannels(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{testFileName: []byte("data")}
|
files := map[string][]byte{"file.txt": []byte("data")}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should not panic with nil channels
|
// Should not panic with nil channels
|
||||||
@@ -667,18 +376,12 @@ func TestCheckNilChannels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFindExtraFilesNilChannel(t *testing.T) {
|
func TestFindExtraFilesNilChannel(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{testFileName: []byte("data")}
|
files := map[string][]byte{"file.txt": []byte("data")}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should not panic with nil channel
|
// Should not panic with nil channel
|
||||||
@@ -687,29 +390,22 @@ func TestFindExtraFilesNilChannel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckSubdirectories(t *testing.T) {
|
func TestCheckSubdirectories(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
"dir1/file1.txt": []byte("content1"),
|
"dir1/file1.txt": []byte("content1"),
|
||||||
"dir1/dir2/file2.txt": []byte("content2"),
|
"dir1/dir2/file2.txt": []byte("content2"),
|
||||||
"dir1/dir2/dir3/deep.txt": []byte("deep content"),
|
"dir1/dir2/dir3/deep.txt": []byte("deep content"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
|
||||||
// Create files with full directory structure
|
// Create files with full directory structure
|
||||||
for path, content := range files {
|
for path, content := range files {
|
||||||
fullPath := "/data/" + path
|
fullPath := "/data/" + path
|
||||||
|
|
||||||
require.NoError(t, fs.MkdirAll("/data/dir1/dir2/dir3", 0o755))
|
require.NoError(t, fs.MkdirAll("/data/dir1/dir2/dir3", 0o755))
|
||||||
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -717,37 +413,28 @@ func TestCheckSubdirectories(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var okCount int
|
var okCount int
|
||||||
|
|
||||||
for r := range results {
|
for r := range results {
|
||||||
assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path)
|
assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path)
|
||||||
|
|
||||||
okCount++
|
okCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, 3, okCount)
|
assert.Equal(t, 3, okCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Regression test: errors.Is(err, errors.New("...")) never matches because
|
// Regression test: errors.Is(err, errors.New("...")) never matches because
|
||||||
// errors.New creates a new value each time. The fix uses os.ErrNotExist instead.
|
// errors.New creates a new value each time. The fix uses os.ErrNotExist instead.
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testExistsFile: []byte("here"),
|
"exists.txt": []byte("here"),
|
||||||
"missing.txt": []byte("not on disk"),
|
"missing.txt": []byte("not on disk"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
// Only create one file on disk
|
// Only create one file on disk
|
||||||
createFilesOnDisk(t, fs, map[string][]byte{
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
testExistsFile: []byte("here"),
|
"exists.txt": []byte("here"),
|
||||||
})
|
})
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 10)
|
results := make(chan Result, 10)
|
||||||
@@ -761,24 +448,72 @@ func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
|||||||
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
|
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, 1, statusCounts[StatusOK], "one file should be OK")
|
assert.Equal(t, 1, statusCounts[StatusOK], "one file should be OK")
|
||||||
assert.Equal(t, 1, statusCounts[StatusMissing], "one file should be MISSING")
|
assert.Equal(t, 1, statusCounts[StatusMissing], "one file should be MISSING")
|
||||||
assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR")
|
assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckEmptyManifest(t *testing.T) {
|
func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
|
||||||
t.Parallel()
|
// Regression test for #16: FindExtraFiles should not report dotfiles
|
||||||
|
// or the manifest file itself as extra files.
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": []byte("in manifest"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/data/.index.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
// Add dotfiles and manifest file on disk
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("dotfile"), 0o644))
|
||||||
|
require.NoError(t, fs.MkdirAll("/data/.git", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
|
||||||
|
|
||||||
|
chk, err := NewChecker("/data/.index.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var extras []Result
|
||||||
|
for r := range results {
|
||||||
|
extras = append(extras, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should report NO extra files — dotfiles and manifest should be skipped
|
||||||
|
assert.Empty(t, extras, "FindExtraFiles should not report dotfiles or manifest file as extra; got: %v", extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFilesSkipsManifestFile(t *testing.T) {
|
||||||
|
// The manifest file itself should never be reported as extra
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": []byte("content"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/data/index.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/data/index.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var extras []Result
|
||||||
|
for r := range results {
|
||||||
|
extras = append(extras, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, extras, "manifest file should not be reported as extra; got: %v", extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckEmptyManifest(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
// Create manifest with no files
|
// Create manifest with no files
|
||||||
createTestManifest(t, fs, testManifestPath, map[string][]byte{})
|
createTestManifest(t, fs, "/manifest.mf", map[string][]byte{})
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, FileCount(0), chk.FileCount())
|
assert.Equal(t, FileCount(0), chk.FileCount())
|
||||||
@@ -792,32 +527,23 @@ func TestCheckEmptyManifest(t *testing.T) {
|
|||||||
for range results {
|
for range results {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, 0, count)
|
assert.Equal(t, 0, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckProgressRateLimited(t *testing.T) {
|
func TestCheckProgressRateLimited(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create many small files - progress should be rate-limited, not one per file.
|
// Create many small files - progress should be rate-limited, not one per file.
|
||||||
// With rate-limiting to once per second, we should get far fewer progress
|
// With rate-limiting to once per second, we should get far fewer progress
|
||||||
// updates than files (plus one final update).
|
// updates than files (plus one final update).
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
files := make(map[string][]byte, 100)
|
files := make(map[string][]byte, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
for i := range 100 {
|
|
||||||
name := fmt.Sprintf("file%03d.txt", i)
|
name := fmt.Sprintf("file%03d.txt", i)
|
||||||
files[name] = []byte("content")
|
files[name] = []byte("content")
|
||||||
}
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
createTestManifest(t, fs, testManifestPath, files)
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
createFilesOnDisk(t, fs, files)
|
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
|
||||||
ManifestPath: testManifestPath,
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := make(chan Result, 200)
|
results := make(chan Result, 200)
|
||||||
@@ -825,7 +551,9 @@ func TestCheckProgressRateLimited(t *testing.T) {
|
|||||||
err = chk.Check(context.Background(), results, progress)
|
err = chk.Check(context.Background(), results, progress)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// results is fully buffered and closed; no draining needed
|
// Drain results
|
||||||
|
for range results {
|
||||||
|
}
|
||||||
|
|
||||||
// Count progress updates
|
// Count progress updates
|
||||||
var progressCount int
|
var progressCount int
|
||||||
@@ -835,8 +563,6 @@ func TestCheckProgressRateLimited(t *testing.T) {
|
|||||||
|
|
||||||
// Should be far fewer than 100 (rate-limited to once per second)
|
// Should be far fewer than 100 (rate-limited to once per second)
|
||||||
// At minimum we get the final update
|
// At minimum we get the final update
|
||||||
assert.GreaterOrEqual(t, progressCount, 1,
|
assert.GreaterOrEqual(t, progressCount, 1, "should get at least the final progress update")
|
||||||
"should get at least the final progress update")
|
assert.Less(t, progressCount, 100, "progress should be rate-limited, not one per file")
|
||||||
assert.Less(t, progressCount, 100,
|
|
||||||
"progress should be rate-limited, not one per file")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-34
@@ -1,44 +1,11 @@
|
|||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current mfer release version.
|
Version = "0.1.0"
|
||||||
Version = "0.1.0"
|
|
||||||
|
|
||||||
// ReleaseDate is the date on which Version was released.
|
|
||||||
ReleaseDate = "2025-12-17"
|
ReleaseDate = "2025-12-17"
|
||||||
|
|
||||||
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
||||||
// data (256 MB). This prevents decompression bombs from consuming excessive
|
// data (256 MB). This prevents decompression bombs from consuming excessive
|
||||||
// memory.
|
// memory.
|
||||||
MaxDecompressedSize int64 = 256 * 1024 * 1024
|
MaxDecompressedSize int64 = 256 * 1024 * 1024
|
||||||
|
|
||||||
// zstdWindowSize is the zstd window zstd.SpeedBestCompression gives mfer's writer.
|
|
||||||
zstdWindowSize = 8 << 20
|
|
||||||
|
|
||||||
// uuidLength is the length in bytes of a binary UUID.
|
|
||||||
uuidLength = 16
|
|
||||||
|
|
||||||
// Numbers in mf.proto of MFFile.files and of the MFFilePath fields
|
|
||||||
// that decoding sets aside a fixed amount of memory for.
|
|
||||||
filesFieldNumber = 101
|
|
||||||
hashesFieldNumber = 3
|
|
||||||
mimeTypeFieldNumber = 301
|
|
||||||
mtimeFieldNumber = 302
|
|
||||||
ctimeFieldNumber = 303
|
|
||||||
|
|
||||||
// Bytes decoding sets aside for each file entry, hash, timestamp and
|
|
||||||
// MIME type, however short its encoding. checkDecodedSize refuses an
|
|
||||||
// inner message for which these add up to more than maxDecodedGrowth
|
|
||||||
// times its size. The mode is held in the file entry itself.
|
|
||||||
decodedFileEntrySize = 176
|
|
||||||
decodedHashSize = 112
|
|
||||||
decodedTimestampSize = 64
|
|
||||||
decodedMIMETypeSize = 16
|
|
||||||
|
|
||||||
// Each file entry mfer writes holds a path of at least one byte, a
|
|
||||||
// multihash at least as long as SHA-256's 34 bytes (AddFileWithHash
|
|
||||||
// refuses shorter ones) and a modification time: at least 47 bytes,
|
|
||||||
// counted at 352. So its manifests add up to at most about 7.49 times
|
|
||||||
// their size, and this limit is about 7% above that.
|
|
||||||
maxDecodedGrowth = 8
|
|
||||||
)
|
)
|
||||||
|
|||||||
+57
-264
@@ -2,297 +2,118 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"sneak.berlin/go/mfer/internal/bork"
|
"sneak.berlin/go/mfer/internal/bork"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// validateUUID checks that the byte slice is a valid UUID (16 bytes, parseable).
|
||||||
errInvalidUUIDLength = errors.New("invalid UUID length")
|
|
||||||
errUnknownVersion = errors.New("unknown version")
|
|
||||||
errUnknownCompression = errors.New("unknown compression type")
|
|
||||||
errCompressedHashWrong = errors.New("compressed data hash mismatch")
|
|
||||||
errSignatureNoPubKey = errors.New("signature present but no public key")
|
|
||||||
errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size")
|
|
||||||
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
|
||||||
errInvalidFileFormat = errors.New("invalid file format")
|
|
||||||
errInvalidManifestPath = errors.New("manifest contains invalid path")
|
|
||||||
errDecodedTooLarge = errors.New(
|
|
||||||
"manifest would take too much memory to decode")
|
|
||||||
)
|
|
||||||
|
|
||||||
// validateUUID checks that the byte slice is the 16 bytes of a binary UUID.
|
|
||||||
// Any 16 bytes are one, so the length is all there is to check.
|
|
||||||
func validateUUID(data []byte) error {
|
func validateUUID(data []byte) error {
|
||||||
if len(data) != uuidLength {
|
if len(data) != 16 {
|
||||||
return errInvalidUUIDLength
|
return errors.New("invalid UUID length")
|
||||||
|
}
|
||||||
|
// Try to parse as UUID to validate format
|
||||||
|
_, err := uuid.FromBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid UUID format")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateOuterHeader checks the outer message's version, compression
|
func (m *manifest) deserializeInner() error {
|
||||||
// type, and UUID.
|
if m.pbOuter.Version != MFFileOuter_VERSION_ONE {
|
||||||
func (m *manifest) validateOuterHeader() error {
|
return errors.New("unknown version")
|
||||||
if m.pbOuter.GetVersion() != MFFileOuter_VERSION_ONE {
|
|
||||||
return errUnknownVersion
|
|
||||||
}
|
}
|
||||||
|
if m.pbOuter.CompressionType != MFFileOuter_COMPRESSION_ZSTD {
|
||||||
if m.pbOuter.GetCompressionType() != MFFileOuter_COMPRESSION_ZSTD {
|
return errors.New("unknown compression type")
|
||||||
return errUnknownCompression
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate outer UUID before any decompression
|
// Validate outer UUID before any decompression
|
||||||
err := validateUUID(m.pbOuter.GetUuid())
|
if err := validateUUID(m.pbOuter.Uuid); err != nil {
|
||||||
if err != nil {
|
return errors.New("outer UUID invalid: " + err.Error())
|
||||||
return fmt.Errorf("outer UUID invalid: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// Verify hash of compressed data before decompression
|
||||||
}
|
|
||||||
|
|
||||||
// verifyOuterIntegrity checks the hash of the compressed payload and,
|
|
||||||
// if a signature is present, verifies it against the embedded public key.
|
|
||||||
func (m *manifest) verifyOuterIntegrity() error {
|
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
if _, err := h.Write(m.pbOuter.InnerMessage); err != nil {
|
||||||
_, err := h.Write(m.pbOuter.GetInnerMessage())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deserialize: hash write: %w", err)
|
return fmt.Errorf("deserialize: hash write: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sha256Hash := h.Sum(nil)
|
sha256Hash := h.Sum(nil)
|
||||||
if !bytes.Equal(sha256Hash, m.pbOuter.GetSha256()) {
|
if !bytes.Equal(sha256Hash, m.pbOuter.Sha256) {
|
||||||
return errCompressedHashWrong
|
return errors.New("compressed data hash mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.pbOuter.GetSignature()) == 0 {
|
// Verify signature if present
|
||||||
return nil
|
if len(m.pbOuter.Signature) > 0 {
|
||||||
|
if len(m.pbOuter.SigningPubKey) == 0 {
|
||||||
|
return errors.New("signature present but no public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
sigString, err := m.signatureString()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate signature string for verification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gpgVerify([]byte(sigString), m.pbOuter.Signature, m.pbOuter.SigningPubKey); err != nil {
|
||||||
|
return fmt.Errorf("signature verification failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Infof("signature verified successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.pbOuter.GetSigningPubKey()) == 0 {
|
bb := bytes.NewBuffer(m.pbOuter.InnerMessage)
|
||||||
return errSignatureNoPubKey
|
|
||||||
}
|
|
||||||
|
|
||||||
sigString, err := m.signatureString()
|
zr, err := zstd.NewReader(bb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf("deserialize: zstd reader: %w", err)
|
||||||
"failed to generate signature string for verification: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading a manifest takes no context; gpgTimeout still bounds gpg.
|
|
||||||
err = gpgVerify(
|
|
||||||
context.Background(),
|
|
||||||
[]byte(sigString),
|
|
||||||
m.pbOuter.GetSignature(),
|
|
||||||
m.pbOuter.GetSigningPubKey(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("signature verification failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("signature verified successfully")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decompressInner decompresses the inner payload, enforcing size limits
|
|
||||||
// to prevent decompression bombs.
|
|
||||||
func (m *manifest) decompressInner() ([]byte, error) {
|
|
||||||
bb := bytes.NewBuffer(m.pbOuter.GetInnerMessage())
|
|
||||||
|
|
||||||
// By default the decoder decodes a payload under 128 KiB in full,
|
|
||||||
// each frame up to the decoder's limit, before the LimitReader below
|
|
||||||
// reads any of it. Decoding synchronously and never in full makes it
|
|
||||||
// decode only what the LimitReader asks for. It also sets aside a new
|
|
||||||
// buffer for each frame that asks for a larger window than the frames
|
|
||||||
// before it, even a frame holding no data. Refusing windows above
|
|
||||||
// zstdWindowSize keeps each buffer to a little over zstdWindowSize, and
|
|
||||||
// the buffers of frames holding no data to about 16 times it in total.
|
|
||||||
zr, err := zstd.NewReader(bb,
|
|
||||||
zstd.WithDecoderConcurrency(1),
|
|
||||||
zstd.WithDecodeBuffersBelow(0),
|
|
||||||
zstd.WithDecoderMaxWindow(zstdWindowSize))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("deserialize: zstd reader: %w", err)
|
|
||||||
}
|
}
|
||||||
defer zr.Close()
|
defer zr.Close()
|
||||||
|
|
||||||
// Limit decompressed size to prevent decompression bombs.
|
// Limit decompressed size to prevent decompression bombs.
|
||||||
// Use declared size + 1 byte to detect overflow, capped at MaxDecompressedSize.
|
// Use declared size + 1 byte to detect overflow, capped at MaxDecompressedSize.
|
||||||
maxSize := MaxDecompressedSize
|
maxSize := MaxDecompressedSize
|
||||||
if m.pbOuter.GetSize() > 0 && m.pbOuter.GetSize() < maxSize {
|
if m.pbOuter.Size > 0 && m.pbOuter.Size < int64(maxSize) {
|
||||||
maxSize = m.pbOuter.GetSize() + 1
|
maxSize = int64(m.pbOuter.Size) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
limitedReader := io.LimitReader(zr, maxSize)
|
limitedReader := io.LimitReader(zr, maxSize)
|
||||||
|
|
||||||
dat, err := io.ReadAll(limitedReader)
|
dat, err := io.ReadAll(limitedReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("deserialize: decompress: %w", err)
|
return fmt.Errorf("deserialize: decompress: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if int64(len(dat)) >= MaxDecompressedSize {
|
if int64(len(dat)) >= MaxDecompressedSize {
|
||||||
return nil, fmt.Errorf(
|
return fmt.Errorf("decompressed data exceeds maximum allowed size of %d bytes", MaxDecompressedSize)
|
||||||
"%w of %d bytes", errDecompressedTooLarge, MaxDecompressedSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dat, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDecodedSize refuses an encoded inner message whose file entries,
|
|
||||||
// hashes, timestamps and MIME types would take more than maxDecodedGrowth
|
|
||||||
// times its size to decode. Decoding sets aside a fixed amount for each,
|
|
||||||
// however short its encoding, so a message of empty ones would take about
|
|
||||||
// 50 times its size.
|
|
||||||
func checkDecodedSize(inner []byte) error {
|
|
||||||
limit := maxDecodedGrowth * int64(len(inner))
|
|
||||||
|
|
||||||
var decoded int64
|
|
||||||
|
|
||||||
add := func(size int64) error {
|
|
||||||
decoded += size
|
|
||||||
if decoded > limit {
|
|
||||||
return errDecodedTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return forEachBytesField(inner, func(num protowire.Number, entry []byte) error {
|
|
||||||
if num != filesFieldNumber {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := add(decodedFileEntrySize)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return forEachBytesField(entry, func(num protowire.Number, _ []byte) error {
|
|
||||||
if num == hashesFieldNumber {
|
|
||||||
return add(decodedHashSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if num == mtimeFieldNumber || num == ctimeFieldNumber {
|
|
||||||
return add(decodedTimestampSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if num == mimeTypeFieldNumber {
|
|
||||||
return add(decodedMIMETypeSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// forEachBytesField calls fn with the number and value of each
|
|
||||||
// length-delimited field in the encoded message msg, and fails if msg is
|
|
||||||
// malformed.
|
|
||||||
func forEachBytesField(
|
|
||||||
msg []byte, fn func(num protowire.Number, value []byte) error,
|
|
||||||
) error {
|
|
||||||
for len(msg) > 0 {
|
|
||||||
num, wireType, tagLen := protowire.ConsumeTag(msg)
|
|
||||||
if tagLen < 0 {
|
|
||||||
return protowire.ParseError(tagLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
valueLen := protowire.ConsumeFieldValue(num, wireType, msg[tagLen:])
|
|
||||||
if valueLen < 0 {
|
|
||||||
return protowire.ParseError(valueLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wireType == protowire.BytesType {
|
|
||||||
value, _ := protowire.ConsumeBytes(msg[tagLen:])
|
|
||||||
|
|
||||||
err := fn(num, value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msg = msg[tagLen+valueLen:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *manifest) deserializeInner() error {
|
|
||||||
err := m.validateOuterHeader()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.verifyOuterIntegrity()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dat, err := m.decompressInner()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isize := len(dat)
|
isize := len(dat)
|
||||||
if int64(isize) != m.pbOuter.GetSize() {
|
if int64(isize) != m.pbOuter.Size {
|
||||||
log.Debugf("truncated data, got %d expected %d", isize, m.pbOuter.GetSize())
|
log.Debugf("truncated data, got %d expected %d", isize, m.pbOuter.Size)
|
||||||
|
|
||||||
return bork.ErrFileTruncated
|
return bork.ErrFileTruncated
|
||||||
}
|
}
|
||||||
|
|
||||||
err = checkDecodedSize(dat)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deserialize inner message
|
// Deserialize inner message
|
||||||
m.pbInner = new(MFFile)
|
m.pbInner = new(MFFile)
|
||||||
|
if err := proto.Unmarshal(dat, m.pbInner); err != nil {
|
||||||
// Unknown fields would cost memory; mfer never writes a loaded manifest out.
|
|
||||||
err = proto.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(dat, m.pbInner)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate inner UUID
|
// Validate inner UUID
|
||||||
err = validateUUID(m.pbInner.GetUuid())
|
if err := validateUUID(m.pbInner.Uuid); err != nil {
|
||||||
if err != nil {
|
return errors.New("inner UUID invalid: " + err.Error())
|
||||||
return fmt.Errorf("inner UUID invalid: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify UUIDs match
|
// Verify UUIDs match
|
||||||
if !bytes.Equal(m.pbOuter.GetUuid(), m.pbInner.GetUuid()) {
|
if !bytes.Equal(m.pbOuter.Uuid, m.pbInner.Uuid) {
|
||||||
return errUUIDMismatch
|
return errors.New("outer and inner UUID mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce the manifest path invariants on every entry as it is loaded,
|
log.Infof("loaded manifest with %d files", len(m.pbInner.Files))
|
||||||
// so that no consumer of a manifest — Checker today, any restore or
|
|
||||||
// extract path tomorrow — acts on a traversal or absolute path from an
|
|
||||||
// untrusted .mf. Reject loudly on the first offender rather than
|
|
||||||
// dropping entries, which would let a hostile manifest hide files from a
|
|
||||||
// check.
|
|
||||||
for _, f := range m.pbInner.GetFiles() {
|
|
||||||
err = ValidatePath(f.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles()))
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,26 +122,20 @@ func validateMagic(dat []byte) bool {
|
|||||||
if len(dat) < ml {
|
if len(dat) < ml {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
got := dat[0:ml]
|
got := dat[0:ml]
|
||||||
expected := []byte(MAGIC)
|
expected := []byte(MAGIC)
|
||||||
|
|
||||||
return bytes.Equal(got, expected)
|
return bytes.Equal(got, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManifestFromReader reads a manifest from an io.Reader.
|
// NewManifestFromReader reads a manifest from an io.Reader.
|
||||||
//
|
|
||||||
//nolint:revive // unexported-return: exporting manifest is owner question 13
|
|
||||||
func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
||||||
m := &manifest{}
|
m := &manifest{}
|
||||||
|
|
||||||
dat, err := io.ReadAll(input)
|
dat, err := io.ReadAll(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validateMagic(dat) {
|
if !validateMagic(dat) {
|
||||||
return nil, errInvalidFileFormat
|
return nil, errors.New("invalid file format")
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove magic bytes prefix:
|
// remove magic bytes prefix:
|
||||||
@@ -330,50 +145,28 @@ func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
|||||||
|
|
||||||
// deserialize outer:
|
// deserialize outer:
|
||||||
m.pbOuter = new(MFFileOuter)
|
m.pbOuter = new(MFFileOuter)
|
||||||
|
if err := proto.Unmarshal(dat, m.pbOuter); err != nil {
|
||||||
// Unknown fields would cost memory; mfer never writes a loaded manifest out.
|
|
||||||
err = proto.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(dat, m.pbOuter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// deserialize inner:
|
// deserialize inner:
|
||||||
err = m.deserializeInner()
|
if err := m.deserializeInner(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManifestFromFileOptions configures NewManifestFromFile.
|
// NewManifestFromFile reads a manifest from a file path using the given filesystem.
|
||||||
type ManifestFromFileOptions struct {
|
// If fs is nil, the real filesystem (OsFs) is used.
|
||||||
// Path is the manifest file to read (required).
|
func NewManifestFromFile(fs afero.Fs, path string) (*manifest, error) {
|
||||||
Path string
|
|
||||||
// Fs is the filesystem to use, defaults to OsFs if nil.
|
|
||||||
Fs afero.Fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManifestFromFile reads a manifest from a file. It returns an error if
|
|
||||||
// opts is nil or its path is empty.
|
|
||||||
//
|
|
||||||
//nolint:revive // unexported-return: exporting manifest is owner question 13
|
|
||||||
func NewManifestFromFile(opts *ManifestFromFileOptions) (*manifest, error) {
|
|
||||||
if opts == nil || opts.Path == "" {
|
|
||||||
return nil, errManifestPathEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := opts.Fs
|
|
||||||
if fs == nil {
|
if fs == nil {
|
||||||
fs = afero.NewOsFs()
|
fs = afero.NewOsFs()
|
||||||
}
|
}
|
||||||
|
f, err := fs.Open(path)
|
||||||
f, err := fs.Open(opts.Path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
return NewManifestFromReader(f)
|
return NewManifestFromReader(f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FuzzNewManifestFromReader feeds arbitrary bytes to the manifest parser.
|
|
||||||
// `make test` runs it on the seed corpus in
|
|
||||||
// testdata/fuzz/FuzzNewManifestFromReader; `make fuzz` searches for new
|
|
||||||
// inputs.
|
|
||||||
//
|
|
||||||
// For every input the parser must return a manifest or an error, not both
|
|
||||||
// and not neither, and must not allocate more than a fixed multiple of its
|
|
||||||
// input and of the decompressed data it may read, plus room for the
|
|
||||||
// decoder's window buffers. A panic or a hang fails the test on its own.
|
|
||||||
func FuzzNewManifestFromReader(f *testing.F) {
|
|
||||||
// A signed manifest makes the parser write the key and signature to a
|
|
||||||
// temporary directory and run gpg on them. With gpg off the PATH and
|
|
||||||
// temporary files kept in the test's own directory, no process is
|
|
||||||
// started and nothing is written elsewhere; such input ends in an
|
|
||||||
// error instead.
|
|
||||||
f.Setenv("PATH", "")
|
|
||||||
f.Setenv("TMPDIR", f.TempDir())
|
|
||||||
|
|
||||||
f.Fuzz(func(t *testing.T, data []byte) {
|
|
||||||
var before, after runtime.MemStats
|
|
||||||
|
|
||||||
runtime.ReadMemStats(&before)
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(bytes.NewReader(data))
|
|
||||||
|
|
||||||
runtime.ReadMemStats(&after)
|
|
||||||
|
|
||||||
if (m == nil) == (err == nil) {
|
|
||||||
t.Fatalf("got manifest %p and error %v, want exactly one", m, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The parser reads at most the declared size plus one byte of
|
|
||||||
// decompressed data, and never more than MaxDecompressedSize.
|
|
||||||
decompressed := uint64(MaxDecompressedSize)
|
|
||||||
|
|
||||||
outer := new(MFFileOuter)
|
|
||||||
if validateMagic(data) &&
|
|
||||||
proto.Unmarshal(data[len(MAGIC):], outer) == nil {
|
|
||||||
size := outer.GetSize()
|
|
||||||
if size > 0 && size < MaxDecompressedSize {
|
|
||||||
decompressed = uint64(size) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// It also keeps a few copies of its input. Buffers grow by
|
|
||||||
// copying, so reaching those sizes allocates up to about six times
|
|
||||||
// them in total. Decoding the decompressed data takes up to
|
|
||||||
// maxDecodedGrowth times its size for file entries, hashes,
|
|
||||||
// timestamps and MIME types, and drops fields it does not know.
|
|
||||||
// The strings and bytes it copies out of it, such as many one-byte
|
|
||||||
// values in one hash, take up to about five times more under the
|
|
||||||
// race detector, which pads every small copy to 16 bytes, and about
|
|
||||||
// half that without it. Twenty times the input and the
|
|
||||||
// decompressed data leaves room for all of that.
|
|
||||||
//
|
|
||||||
// The decoder also sets aside a new buffer of one to two times the
|
|
||||||
// window for each frame that asks for a larger window than the
|
|
||||||
// frames before it, and refuses windows above zstdWindowSize. A
|
|
||||||
// frame that gives its content size instead of a window has that
|
|
||||||
// size as its window, refused above zstdWindowSize like any other.
|
|
||||||
// Frames asking for every window size up to that make it set aside
|
|
||||||
// about 16 times zstdWindowSize in total; 24 times leaves room.
|
|
||||||
//
|
|
||||||
// The seed whose frame claims 8 GiB fails if the decoder sets that
|
|
||||||
// size aside; the seed whose frames ask for ever larger windows
|
|
||||||
// fails if the decoder accepts windows of twice zstdWindowSize; the
|
|
||||||
// seed whose two frames together exceed MaxDecompressedSize fails
|
|
||||||
// if the decoder decodes them in full instead of stopping at the
|
|
||||||
// declared size; the seeds of empty file entries and of a file
|
|
||||||
// entry of empty hashes fail if the parser decodes them.
|
|
||||||
limit := 20*(uint64(len(data))+decompressed) + 24*zstdWindowSize
|
|
||||||
|
|
||||||
allocated := after.TotalAlloc - before.TotalAlloc
|
|
||||||
if allocated > limit {
|
|
||||||
t.Fatalf("allocated %d bytes for %d bytes of input, limit %d",
|
|
||||||
allocated, len(data), limit)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
"uuid"
|
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/multiformats/go-multihash"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// craftInnerBytes builds the wire bytes of an inner MFFile holding a single
|
|
||||||
// file entry whose path is exactly pathBytes. It writes the wire form by hand
|
|
||||||
// so a hostile path — including one that is not valid UTF-8 — can be embedded
|
|
||||||
// without proto.Marshal's own UTF-8 enforcement rejecting it first.
|
|
||||||
func craftInnerBytes(id uuid.UUID, pathBytes string) []byte {
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
|
||||||
entry = protowire.AppendString(entry, pathBytes)
|
|
||||||
|
|
||||||
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version
|
|
||||||
inner = protowire.AppendVarint(inner, uint64(MFFile_VERSION_ONE))
|
|
||||||
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
|
|
||||||
inner = protowire.AppendBytes(inner, entry)
|
|
||||||
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
|
|
||||||
inner = protowire.AppendBytes(inner, id[:])
|
|
||||||
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapInner wraps inner MFFile wire bytes in a complete, well-formed .mf
|
|
||||||
// envelope (magic prefix, zstd-compressed payload, matching hash and UUID) so
|
|
||||||
// that deserialization reaches path validation rather than failing earlier on
|
|
||||||
// an integrity check.
|
|
||||||
func wrapInner(t *testing.T, id uuid.UUID, innerData []byte) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var cbuf bytes.Buffer
|
|
||||||
|
|
||||||
zw, err := zstd.NewWriter(&cbuf, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = zw.Write(innerData)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, zw.Close())
|
|
||||||
|
|
||||||
compressed := cbuf.Bytes()
|
|
||||||
sum := sha256.Sum256(compressed)
|
|
||||||
|
|
||||||
outer := &MFFileOuter{
|
|
||||||
InnerMessage: compressed,
|
|
||||||
Size: int64(len(innerData)),
|
|
||||||
Sha256: sum[:],
|
|
||||||
Uuid: id[:],
|
|
||||||
Version: MFFileOuter_VERSION_ONE,
|
|
||||||
CompressionType: MFFileOuter_COMPRESSION_ZSTD,
|
|
||||||
}
|
|
||||||
|
|
||||||
ob, err := proto.Marshal(outer)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return append([]byte(MAGIC), ob...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
}{
|
|
||||||
{"parent traversal", "../escape"},
|
|
||||||
{"interior traversal", "a/../../escape"},
|
|
||||||
{"absolute path", "/etc/passwd"},
|
|
||||||
{"backslash path", `a\b`},
|
|
||||||
{"double slash", "a//b"},
|
|
||||||
{"empty path", ""},
|
|
||||||
{"invalid utf-8", "abc\xff"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id := uuid.New()
|
|
||||||
data := wrapInner(t, id, craftInnerBytes(id, tt.path))
|
|
||||||
|
|
||||||
_, err := NewManifestFromReader(bytes.NewReader(data))
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
if tt.path == "abc\xff" {
|
|
||||||
// A path that is not valid UTF-8 cannot survive the proto3
|
|
||||||
// string decoder, which rejects it before path validation
|
|
||||||
// runs; the manifest is still refused at load time.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errInvalidManifestPath)
|
|
||||||
|
|
||||||
if tt.path != "" {
|
|
||||||
// ValidatePath quotes the path with %q; assert against the
|
|
||||||
// same rendering so escaped characters (e.g. a backslash)
|
|
||||||
// still match.
|
|
||||||
assert.Contains(t, err.Error(), fmt.Sprintf("%q", tt.path),
|
|
||||||
"error must name the offending path")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entries of a path, an empty hash, an empty MIME type and empty modification
|
|
||||||
// and change times are counted at 432 bytes each (176 + 112 + 16 + 64 + 64)
|
|
||||||
// and take 16 bytes plus the path to encode. A 37-character path makes that
|
|
||||||
// 53 bytes, about 8.2 times: refused, and leaving any one of the five
|
|
||||||
// uncounted, even the MIME type, brings it under 8. A 39-character path makes
|
|
||||||
// it 55 bytes, about 7.9 times: loaded.
|
|
||||||
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
pathLen int
|
|
||||||
refused bool
|
|
||||||
}{
|
|
||||||
{37, true},
|
|
||||||
{39, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(strconv.Itoa(tt.pathLen), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
|
||||||
entry = protowire.AppendString(entry, strings.Repeat("a", tt.pathLen))
|
|
||||||
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
|
||||||
entry = protowire.AppendBytes(entry, nil)
|
|
||||||
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
|
|
||||||
entry = protowire.AppendBytes(entry, nil)
|
|
||||||
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
|
|
||||||
entry = protowire.AppendBytes(entry, nil)
|
|
||||||
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // MFFilePath.ctime
|
|
||||||
entry = protowire.AppendBytes(entry, nil)
|
|
||||||
|
|
||||||
id := uuid.New()
|
|
||||||
inner := protowire.AppendTag(nil, 102, protowire.BytesType) // MFFile.uuid
|
|
||||||
inner = protowire.AppendBytes(inner, id[:])
|
|
||||||
|
|
||||||
for range 1000 {
|
|
||||||
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
|
|
||||||
inner = protowire.AppendBytes(inner, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
|
||||||
if tt.refused {
|
|
||||||
require.ErrorIs(t, err, errDecodedTooLarge)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fields the decoder does not know are dropped, in the outer message, the inner
|
|
||||||
// message and a file entry, so that they take no memory once loaded.
|
|
||||||
func TestDeserializeDropsUnknownFields(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := protowire.AppendTag(nil, 99, protowire.BytesType) // in no message
|
|
||||||
unknown = protowire.AppendBytes(unknown, []byte("not known"))
|
|
||||||
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
|
||||||
entry = protowire.AppendString(entry, "a")
|
|
||||||
entry = append(entry, unknown...)
|
|
||||||
|
|
||||||
id := uuid.New()
|
|
||||||
inner := protowire.AppendTag(nil, 101, protowire.BytesType) // MFFile.files
|
|
||||||
inner = protowire.AppendBytes(inner, entry)
|
|
||||||
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
|
|
||||||
inner = protowire.AppendBytes(inner, id[:])
|
|
||||||
inner = append(inner, unknown...)
|
|
||||||
|
|
||||||
data := wrapInner(t, id, inner)
|
|
||||||
data = append(data, unknown...) // the outer message ends the file
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(bytes.NewReader(data))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, m.Files(), 1)
|
|
||||||
assert.Empty(t, m.pbOuter.ProtoReflect().GetUnknown())
|
|
||||||
assert.Empty(t, m.pbInner.ProtoReflect().GetUnknown())
|
|
||||||
assert.Empty(t, m.Files()[0].ProtoReflect().GetUnknown())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Many empty files with names of at most three characters and modification
|
|
||||||
// times at the epoch make about the densest manifest mfer writes: it takes
|
|
||||||
// about 7 times its size to decode, and still loads. A signature would not
|
|
||||||
// change the inner message, so none is added.
|
|
||||||
func TestDeserializeLoadsDensestManifest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
b.SetIncludeTimestamps(true)
|
|
||||||
|
|
||||||
const files = 10000
|
|
||||||
for i := range files {
|
|
||||||
name := RelFilePath(strconv.FormatInt(int64(i), 36))
|
|
||||||
require.NoError(t, b.AddFileWithHash(name, 0, ModTime(time.Unix(0, 0)), 0, hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, m.Files(), files)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeserializeValidManifestRoundTrips(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, 0, hash))
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(bytes.NewReader(buf.Bytes()))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files := m.Files()
|
|
||||||
require.Len(t, files, 1)
|
|
||||||
assert.Equal(t, "dir/file.txt", files[0].GetPath())
|
|
||||||
assert.Equal(t, int64(123), files[0].GetSize())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidatePathRejectsInvalidUTF8 pins the ValidatePath rule that a manifest
|
|
||||||
// path must be valid UTF-8, independent of the proto decoder that also enforces
|
|
||||||
// it on the wire.
|
|
||||||
func TestValidatePathRejectsInvalidUTF8(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := ValidatePath("abc\xff")
|
|
||||||
require.ErrorIs(t, err, errPathNotUTF8)
|
|
||||||
assert.Contains(t, err.Error(), "UTF-8")
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestValidatePathMessagesVerbatim pins the exact rendered text of every
|
|
||||||
// ValidatePath rejection.
|
|
||||||
//
|
|
||||||
// These strings are user-visible and are assembled by wrapping static
|
|
||||||
// sentinels mid-sentence, which makes them easy to reword by accident
|
|
||||||
// while refactoring for errors.Is matchability. Changing one is a
|
|
||||||
// deliberate change, not a refactoring side effect.
|
|
||||||
func TestValidatePathMessagesVerbatim(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
is error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
path: "",
|
|
||||||
want: "path cannot be empty",
|
|
||||||
is: errPathEmpty,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not utf8",
|
|
||||||
path: "a\xffb",
|
|
||||||
want: `path "a\xffb" is not valid UTF-8`,
|
|
||||||
is: errPathNotUTF8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "backslash",
|
|
||||||
path: `a\b`,
|
|
||||||
want: `path "a\\b" contains backslash; ` +
|
|
||||||
"use forward slashes only",
|
|
||||||
is: errPathBackslash,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "absolute",
|
|
||||||
path: "/a/b",
|
|
||||||
want: `path "/a/b" is absolute; must be relative`,
|
|
||||||
is: errPathAbsolute,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty segment",
|
|
||||||
path: "a//b",
|
|
||||||
want: `path "a//b" contains empty segment`,
|
|
||||||
is: errPathEmptySegment,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dotdot segment",
|
|
||||||
path: "a/../b",
|
|
||||||
want: `path "a/../b" contains '..' segment`,
|
|
||||||
is: errPathDotDot,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := ValidatePath(tc.path)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Equal(t, tc.want, err.Error())
|
|
||||||
require.ErrorIs(t, err, tc.is)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSerializeInternalErrorMessagesVerbatim pins the two distinct
|
|
||||||
// "internal error" messages, which differ between generate and
|
|
||||||
// generateOuter and have always done so.
|
|
||||||
func TestSerializeInternalErrorMessagesVerbatim(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := &manifest{}
|
|
||||||
require.EqualError(t, m.generate(context.Background()),
|
|
||||||
"internal error: pbInner not set")
|
|
||||||
require.EqualError(t, m.generateOuter(context.Background()), "internal error")
|
|
||||||
}
|
|
||||||
+89
-186
@@ -2,55 +2,11 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// gpgTimeout bounds every gpg run, which can otherwise wait forever on
|
|
||||||
// a passphrase prompt or a stalled gpg-agent. A minute leaves a person
|
|
||||||
// time to type a passphrase or touch a smartcard.
|
|
||||||
gpgTimeout = time.Minute
|
|
||||||
|
|
||||||
// gpgWaitDelay is how long a gpg run keeps waiting for gpg's stdout
|
|
||||||
// and stderr to close once gpg has been killed or has exited. Reading
|
|
||||||
// what gpg itself wrote takes far less; only a process gpg left behind
|
|
||||||
// holds them open longer.
|
|
||||||
gpgWaitDelay = time.Second
|
|
||||||
|
|
||||||
// privateDirPerms is the permission mode for temporary GPG home
|
|
||||||
// directories.
|
|
||||||
privateDirPerms os.FileMode = 0o700
|
|
||||||
|
|
||||||
// privateFilePerms is the permission mode for temporary key,
|
|
||||||
// signature, and data files.
|
|
||||||
privateFilePerms os.FileMode = 0o600
|
|
||||||
|
|
||||||
// gpgFingerprintField is the record type tag for fingerprint lines
|
|
||||||
// in gpg --with-colons output.
|
|
||||||
gpgFingerprintField = "fpr"
|
|
||||||
|
|
||||||
// gpgFingerprintMinFields is the minimum number of colon-separated
|
|
||||||
// fields in a gpg fingerprint record (the fingerprint is field 10).
|
|
||||||
gpgFingerprintMinFields = 10
|
|
||||||
|
|
||||||
// gpg option names used from more than one call site.
|
|
||||||
gpgOptArmor = "--armor"
|
|
||||||
gpgOptHomedir = "--homedir"
|
|
||||||
gpgOptVerify = "--verify"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errGPGKeyNotFound = errors.New("gpg key not found")
|
|
||||||
errFingerprintNotFound = errors.New("fingerprint not found for key")
|
|
||||||
errImportedFPRNotFound = errors.New("fingerprint not found in imported key")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
|
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
|
||||||
@@ -61,90 +17,22 @@ type SigningOptions struct {
|
|||||||
KeyID GPGKeyID
|
KeyID GPGKeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpgArgs builds a gpg argument list from opts followed by positional
|
// gpgSign creates a detached signature of the data using the specified key.
|
||||||
// arguments, separated by an explicit "--" end-of-options marker.
|
// Returns the armored detached signature.
|
||||||
//
|
func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||||
// This matters because key IDs reach gpg as bare positional arguments
|
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
// (from --sign-key / MFER_SIGN_KEY) and gpg would otherwise parse a value
|
"--detach-sign",
|
||||||
// beginning with "-" as one of its own options. Callers must route every
|
"--armor",
|
||||||
// non-option argument through here.
|
"--local-user", string(keyID),
|
||||||
func gpgArgs(opts []string, positional ...string) []string {
|
)
|
||||||
args := make([]string, 0, len(opts)+1+len(positional))
|
|
||||||
args = append(args, opts...)
|
|
||||||
args = append(args, "--")
|
|
||||||
args = append(args, positional...)
|
|
||||||
|
|
||||||
return args
|
cmd.Stdin = bytes.NewReader(data)
|
||||||
}
|
|
||||||
|
|
||||||
// runGPG runs the gpg binary in batch mode with the given arguments and
|
|
||||||
// optional stdin, returning captured stdout and stderr. gpg is killed when
|
|
||||||
// ctx ends or gpgTimeout passes, whichever comes first.
|
|
||||||
func runGPG(
|
|
||||||
ctx context.Context, stdin io.Reader, args ...string,
|
|
||||||
) (*bytes.Buffer, *bytes.Buffer, error) {
|
|
||||||
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg
|
|
||||||
// starts runs detached and holds none of gpg's output, but another
|
|
||||||
// process gpg leaves behind (a wrapper script that runs the real gpg
|
|
||||||
// without exec, for example) can keep gpg's stdout or stderr open, and
|
|
||||||
// Run would wait for it to exit. WaitDelay stops that wait
|
|
||||||
// gpgWaitDelay after the kill; that process is left running.
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, gpgTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
fullArgs := append([]string{"--batch", "--no-tty"}, args...)
|
|
||||||
|
|
||||||
// G204: the executable name is a compile-time constant. The arguments
|
|
||||||
// are not, so the guarantee that matters is placement: every
|
|
||||||
// caller-supplied value is passed either as the value of a named
|
|
||||||
// option or after the "--" end-of-options marker inserted by gpgArgs,
|
|
||||||
// and therefore cannot be reinterpreted by gpg as an option.
|
|
||||||
cmd := exec.CommandContext( //nolint:gosec // G204: see comment above
|
|
||||||
ctx, "gpg", fullArgs...)
|
|
||||||
cmd.WaitDelay = gpgWaitDelay
|
|
||||||
cmd.Stdin = stdin
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil && ctx.Err() != nil {
|
|
||||||
// gpg was killed because ctx ended, which Run reports only as
|
|
||||||
// "signal: killed"; return the reason instead.
|
|
||||||
err = ctx.Err()
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
err = fmt.Errorf("gpg timed out: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &stdout, &stderr, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFingerprint extracts the first fingerprint from gpg --with-colons
|
|
||||||
// output, or returns ok=false if none is present.
|
|
||||||
func parseFingerprint(colonOutput string) (string, bool) {
|
|
||||||
for line := range strings.SplitSeq(colonOutput, "\n") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= gpgFingerprintMinFields &&
|
|
||||||
fields[0] == gpgFingerprintField {
|
|
||||||
return fields[9], true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// gpgSign creates a detached signature of the data using the specified key.
|
|
||||||
// Returns the armored detached signature.
|
|
||||||
func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
|
||||||
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
|
||||||
"--detach-sign",
|
|
||||||
gpgOptArmor,
|
|
||||||
"--local-user", string(keyID),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,156 +41,171 @@ func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
|||||||
|
|
||||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||||
// Returns the armored public key.
|
// Returns the armored public key.
|
||||||
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||||
stdout, stderr, err := runGPG(ctx, nil,
|
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
"--export",
|
||||||
|
"--armor",
|
||||||
|
string(keyID),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String())
|
return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if stdout.Len() == 0 {
|
if stdout.Len() == 0 {
|
||||||
return nil, fmt.Errorf("%w: %s", errGPGKeyNotFound, keyID)
|
return nil, fmt.Errorf("gpg key not found: %s", keyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||||
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||||
stdout, stderr, err := runGPG(ctx, nil,
|
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
"--with-colons",
|
||||||
|
"--fingerprint",
|
||||||
|
string(keyID),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
var stdout, stderr bytes.Buffer
|
||||||
"gpg fingerprint lookup failed: %w: %s", err, stderr.String(),
|
cmd.Stdout = &stdout
|
||||||
)
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("gpg fingerprint lookup failed: %w: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
fpr, ok := parseFingerprint(stdout.String())
|
// Parse the colon-delimited output to find the fingerprint
|
||||||
if !ok {
|
lines := strings.Split(stdout.String(), "\n")
|
||||||
return nil, fmt.Errorf("%w: %s", errFingerprintNotFound, keyID)
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||||
|
return []byte(fields[9]), nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []byte(fpr), nil
|
return nil, fmt.Errorf("fingerprint not found for key: %s", keyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
||||||
// and extracts its fingerprint. This verifies the key is valid and returns
|
// and extracts its fingerprint. This verifies the key is valid and returns
|
||||||
// the actual fingerprint from the key material.
|
// the actual fingerprint from the key material.
|
||||||
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
|
func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||||
// Create temporary directory for GPG operations
|
// Create temporary directory for GPG operations
|
||||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
// Set restrictive permissions
|
// Set restrictive permissions
|
||||||
err = os.Chmod(tmpDir, privateDirPerms)
|
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to set temp dir permissions: %w", err)
|
return "", fmt.Errorf("failed to set temp dir permissions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write public key to temp file
|
// Write public key to temp file
|
||||||
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
||||||
|
if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil {
|
||||||
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to write public key: %w", err)
|
return "", fmt.Errorf("failed to write public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import the public key into the temporary keyring
|
// Import the public key into the temporary keyring
|
||||||
_, importStderr, err := runGPG(ctx, nil,
|
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
"--homedir", tmpDir,
|
||||||
|
"--import",
|
||||||
|
pubKeyFile,
|
||||||
)
|
)
|
||||||
if err != nil {
|
var importStderr bytes.Buffer
|
||||||
return "", fmt.Errorf(
|
importCmd.Stderr = &importStderr
|
||||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
if err := importCmd.Run(); err != nil {
|
||||||
)
|
return "", fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// List keys to get fingerprint
|
// List keys to get fingerprint
|
||||||
listStdout, listStderr, err := runGPG(ctx, nil,
|
listCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
"--homedir", tmpDir,
|
"--homedir", tmpDir,
|
||||||
"--with-colons",
|
"--with-colons",
|
||||||
"--fingerprint",
|
"--fingerprint",
|
||||||
)
|
)
|
||||||
if err != nil {
|
var listStdout, listStderr bytes.Buffer
|
||||||
return "", fmt.Errorf(
|
listCmd.Stdout = &listStdout
|
||||||
"failed to list keys: %w: %s", err, listStderr.String(),
|
listCmd.Stderr = &listStderr
|
||||||
)
|
if err := listCmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to list keys: %w: %s", err, listStderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
fpr, ok := parseFingerprint(listStdout.String())
|
// Parse the colon-delimited output to find the fingerprint
|
||||||
if !ok {
|
lines := strings.Split(listStdout.String(), "\n")
|
||||||
return "", errImportedFPRNotFound
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||||
|
return fields[9], nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fpr, nil
|
return "", fmt.Errorf("fingerprint not found in imported key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpgVerify verifies a detached signature against data using the provided public key.
|
// gpgVerify verifies a detached signature against data using the provided public key.
|
||||||
// It creates a temporary keyring to import the public key for verification.
|
// It creates a temporary keyring to import the public key for verification.
|
||||||
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
|
func gpgVerify(data, signature, pubKey []byte) error {
|
||||||
// Create temporary directory for GPG operations
|
// Create temporary directory for GPG operations
|
||||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
// Set restrictive permissions
|
// Set restrictive permissions
|
||||||
err = os.Chmod(tmpDir, privateDirPerms)
|
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set temp dir permissions: %w", err)
|
return fmt.Errorf("failed to set temp dir permissions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write public key to temp file
|
// Write public key to temp file
|
||||||
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
||||||
|
if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil {
|
||||||
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write public key: %w", err)
|
return fmt.Errorf("failed to write public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write signature to temp file
|
// Write signature to temp file
|
||||||
sigFile := filepath.Join(tmpDir, "signature.asc")
|
sigFile := filepath.Join(tmpDir, "signature.asc")
|
||||||
|
if err := os.WriteFile(sigFile, signature, 0o600); err != nil {
|
||||||
err = os.WriteFile(sigFile, signature, privateFilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write signature: %w", err)
|
return fmt.Errorf("failed to write signature: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write data to temp file
|
// Write data to temp file
|
||||||
dataFile := filepath.Join(tmpDir, "data")
|
dataFile := filepath.Join(tmpDir, "data")
|
||||||
|
if err := os.WriteFile(dataFile, data, 0o600); err != nil {
|
||||||
err = os.WriteFile(dataFile, data, privateFilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write data: %w", err)
|
return fmt.Errorf("failed to write data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import the public key into the temporary keyring
|
// Import the public key into the temporary keyring
|
||||||
_, importStderr, err := runGPG(ctx, nil,
|
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
"--homedir", tmpDir,
|
||||||
|
"--import",
|
||||||
|
pubKeyFile,
|
||||||
)
|
)
|
||||||
if err != nil {
|
var importStderr bytes.Buffer
|
||||||
return fmt.Errorf(
|
importCmd.Stderr = &importStderr
|
||||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
if err := importCmd.Run(); err != nil {
|
||||||
)
|
return fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
_, verifyStderr, err := runGPG(ctx, nil,
|
verifyCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
"--homedir", tmpDir,
|
||||||
sigFile, dataFile)...,
|
"--verify",
|
||||||
|
sigFile,
|
||||||
|
dataFile,
|
||||||
)
|
)
|
||||||
if err != nil {
|
var verifyStderr bytes.Buffer
|
||||||
return fmt.Errorf(
|
verifyCmd.Stderr = &verifyStderr
|
||||||
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
if err := verifyCmd.Run(); err != nil {
|
||||||
)
|
return fmt.Errorf("signature verification failed: %w: %s", err, verifyStderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+88
-229
@@ -1,18 +1,13 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -20,20 +15,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// testGPGEnv sets up a temporary GPG home directory with a test key.
|
// testGPGEnv sets up a temporary GPG home directory with a test key.
|
||||||
// Returns the key ID and the GPG home directory; callers must point
|
// Returns the key ID and a cleanup function.
|
||||||
// GNUPGHOME at the returned directory (via t.Setenv) before using the
|
func testGPGEnv(t *testing.T) (GPGKeyID, func()) {
|
||||||
// gpg helpers under test.
|
|
||||||
func testGPGEnv(t *testing.T) (GPGKeyID, string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Check if gpg is installed
|
// Check if gpg is installed
|
||||||
_, err := exec.LookPath("gpg")
|
if _, err := exec.LookPath("gpg"); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Skip("gpg not installed, skipping signing test")
|
t.Skip("gpg not installed, skipping signing test")
|
||||||
|
return "", func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temporary GPG home directory (0700 by default)
|
// Create temporary GPG home directory
|
||||||
gpgHome := t.TempDir()
|
gpgHome, err := os.MkdirTemp("", "mfer-gpg-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set restrictive permissions on GPG home
|
||||||
|
require.NoError(t, os.Chmod(gpgHome, 0o700))
|
||||||
|
|
||||||
|
// Save original GNUPGHOME and set new one
|
||||||
|
origGPGHome := os.Getenv("GNUPGHOME")
|
||||||
|
require.NoError(t, os.Setenv("GNUPGHOME", gpgHome))
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
if origGPGHome == "" {
|
||||||
|
_ = os.Unsetenv("GNUPGHOME")
|
||||||
|
} else {
|
||||||
|
_ = os.Setenv("GNUPGHOME", origGPGHome)
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(gpgHome)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a test key with no passphrase
|
// Generate a test key with no passphrase
|
||||||
keyParams := `%no-protection
|
keyParams := `%no-protection
|
||||||
@@ -47,57 +57,48 @@ Expire-Date: 0
|
|||||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||||
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
cmd := exec.Command("gpg", "--batch", "--gen-key", paramsFile)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
|
||||||
cmd := exec.CommandContext(ctx, "gpg",
|
|
||||||
"--batch", "--gen-key", paramsFile)
|
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
||||||
|
return "", func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the key fingerprint
|
// Get the key fingerprint
|
||||||
cmd = exec.CommandContext(ctx, "gpg",
|
cmd = exec.Command("gpg", "--list-keys", "--with-colons", "test@mfer.test")
|
||||||
"--list-keys", "--with-colons", "test@mfer.test")
|
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
|
|
||||||
output, err = cmd.Output()
|
output, err = cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
t.Fatalf("failed to list test key: %v", err)
|
t.Fatalf("failed to list test key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse fingerprint from output
|
// Parse fingerprint from output
|
||||||
var keyID string
|
var keyID string
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
for line := range strings.SplitSeq(string(output), "\n") {
|
|
||||||
fields := strings.Split(line, ":")
|
fields := strings.Split(line, ":")
|
||||||
if len(fields) >= gpgFingerprintMinFields &&
|
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||||
fields[0] == gpgFingerprintField {
|
|
||||||
keyID = fields[9]
|
keyID = fields[9]
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyID == "" {
|
if keyID == "" {
|
||||||
|
cleanup()
|
||||||
t.Fatal("failed to find test key fingerprint")
|
t.Fatal("failed to find test key fingerprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
return GPGKeyID(keyID), gpgHome
|
return GPGKeyID(keyID), cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGPGSign(t *testing.T) {
|
func TestGPGSign(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
data := []byte("test data to sign")
|
data := []byte("test data to sign")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, sig)
|
assert.NotEmpty(t, sig)
|
||||||
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
||||||
@@ -105,10 +106,10 @@ func TestGPGSign(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGPGExportPublicKey(t *testing.T) {
|
func TestGPGExportPublicKey(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, pubKey)
|
assert.NotEmpty(t, pubKey)
|
||||||
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
@@ -116,67 +117,29 @@ func TestGPGExportPublicKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGPGGetKeyFingerprint(t *testing.T) {
|
func TestGPGGetKeyFingerprint(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
fingerprint, err := gpgGetKeyFingerprint(context.Background(), keyID)
|
fingerprint, err := gpgGetKeyFingerprint(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, fingerprint)
|
assert.NotEmpty(t, fingerprint)
|
||||||
// The fingerprint should be 40 hex chars
|
// The fingerprint should be 40 hex chars
|
||||||
assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars")
|
assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGPGArgsSeparatesPositionals pins that caller-supplied values are
|
|
||||||
// placed after an end-of-options marker. Key IDs arrive from --sign-key
|
|
||||||
// and MFER_SIGN_KEY as bare positional arguments, so without the marker
|
|
||||||
// a value beginning with "-" would be parsed by gpg as one of its own
|
|
||||||
// options.
|
|
||||||
func TestGPGArgsSeparatesPositionals(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"--opt-a", "--opt-b", "--", "--version"},
|
|
||||||
gpgArgs([]string{"--opt-a", "--opt-b"}, "--version"))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"--opt-c", "--", "sig", "data"},
|
|
||||||
gpgArgs([]string{"--opt-c"}, "sig", "data"))
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"--opt-d", "--"},
|
|
||||||
gpgArgs([]string{"--opt-d"}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGPGOptionLikeKeyIDIsNotAnOption drives real gpg with a key ID that
|
|
||||||
// looks like an option and asserts it is treated as a (nonexistent) key
|
|
||||||
// rather than executed as gpg's own --version.
|
|
||||||
func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
|
|
||||||
_, gpgHome := testGPGEnv(t)
|
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), GPGKeyID("--version"))
|
|
||||||
require.Error(t, err)
|
|
||||||
require.ErrorIs(t, err, errGPGKeyNotFound)
|
|
||||||
assert.NotContains(t, string(pubKey), "gpg (GnuPG)")
|
|
||||||
|
|
||||||
fpr, err := gpgGetKeyFingerprint(context.Background(), GPGKeyID("--version"))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGPGSignInvalidKey(t *testing.T) {
|
func TestGPGSignInvalidKey(t *testing.T) {
|
||||||
// Set up test environment (we need GNUPGHOME set)
|
// Set up test environment (we need GNUPGHOME set)
|
||||||
_, gpgHome := testGPGEnv(t)
|
_, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
data := []byte("test data")
|
data := []byte("test data")
|
||||||
_, err := gpgSign(context.Background(), data,
|
_, err := gpgSign(data, GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
||||||
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderWithSigning(t *testing.T) {
|
func TestBuilderWithSigning(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
// Create a builder with signing options
|
// Create a builder with signing options
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
@@ -187,13 +150,12 @@ func TestBuilderWithSigning(t *testing.T) {
|
|||||||
// Add a test file
|
// Add a test file
|
||||||
content := []byte("test file content")
|
content := []byte("test file content")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Build the manifest
|
// Build the manifest
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
err = b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse the manifest and verify signature fields are populated
|
// Parse the manifest and verify signature fields are populated
|
||||||
@@ -201,32 +163,26 @@ func TestBuilderWithSigning(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, manifest.pbOuter)
|
require.NotNil(t, manifest.pbOuter)
|
||||||
|
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSignature(),
|
assert.NotEmpty(t, manifest.pbOuter.Signature, "signature should be populated")
|
||||||
"signature should be populated")
|
assert.NotEmpty(t, manifest.pbOuter.Signer, "signer should be populated")
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSigner(), "signer should be populated")
|
assert.NotEmpty(t, manifest.pbOuter.SigningPubKey, "signing public key should be populated")
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
||||||
"signing public key should be populated")
|
|
||||||
|
|
||||||
// Verify signature is a valid PGP signature
|
// Verify signature is a valid PGP signature
|
||||||
assert.Contains(t, string(manifest.pbOuter.GetSignature()),
|
assert.Contains(t, string(manifest.pbOuter.Signature), "-----BEGIN PGP SIGNATURE-----")
|
||||||
"-----BEGIN PGP SIGNATURE-----")
|
|
||||||
|
|
||||||
// Verify public key is a valid PGP public key block
|
// Verify public key is a valid PGP public key block
|
||||||
assert.Contains(t, string(manifest.pbOuter.GetSigningPubKey()),
|
assert.Contains(t, string(manifest.pbOuter.SigningPubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerWithSigning(t *testing.T) {
|
func TestScannerWithSigning(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
// Create in-memory filesystem with test files
|
// Create in-memory filesystem with test files
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||||
require.NoError(t,
|
|
||||||
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
|
||||||
|
|
||||||
// Create scanner with signing options
|
// Create scanner with signing options
|
||||||
opts := &ScannerOptions{
|
opts := &ScannerOptions{
|
||||||
@@ -249,61 +205,61 @@ func TestScannerWithSigning(t *testing.T) {
|
|||||||
manifest, err := NewManifestFromReader(&buf)
|
manifest, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
assert.NotEmpty(t, manifest.pbOuter.Signature)
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSigner())
|
assert.NotEmpty(t, manifest.pbOuter.Signer)
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey())
|
assert.NotEmpty(t, manifest.pbOuter.SigningPubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGPGVerify(t *testing.T) {
|
func TestGPGVerify(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
data := []byte("test data to sign and verify")
|
data := []byte("test data to sign and verify")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
err = gpgVerify(context.Background(), data, sig, pubKey)
|
err = gpgVerify(data, sig, pubKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGPGVerifyInvalidSignature(t *testing.T) {
|
func TestGPGVerifyInvalidSignature(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
data := []byte("test data to sign")
|
data := []byte("test data to sign")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Try to verify with different data - should fail
|
// Try to verify with different data - should fail
|
||||||
wrongData := []byte("different data")
|
wrongData := []byte("different data")
|
||||||
err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
err = gpgVerify(wrongData, sig, pubKey)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGPGVerifyBadPublicKey(t *testing.T) {
|
func TestGPGVerifyBadPublicKey(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
data := []byte("test data")
|
data := []byte("test data")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Try to verify with invalid public key - should fail
|
// Try to verify with invalid public key - should fail
|
||||||
badPubKey := []byte("not a valid public key")
|
badPubKey := []byte("not a valid public key")
|
||||||
err = gpgVerify(context.Background(), data, sig, badPubKey)
|
err = gpgVerify(data, sig, badPubKey)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifestSignatureVerification(t *testing.T) {
|
func TestManifestSignatureVerification(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
// Create a builder with signing options
|
// Create a builder with signing options
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
@@ -314,13 +270,12 @@ func TestManifestSignatureVerification(t *testing.T) {
|
|||||||
// Add a test file
|
// Add a test file
|
||||||
content := []byte("test file content for verification")
|
content := []byte("test file content for verification")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Build the manifest
|
// Build the manifest
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
err = b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse the manifest - signature should be verified during load
|
// Parse the manifest - signature should be verified during load
|
||||||
@@ -329,12 +284,12 @@ func TestManifestSignatureVerification(t *testing.T) {
|
|||||||
require.NotNil(t, manifest)
|
require.NotNil(t, manifest)
|
||||||
|
|
||||||
// Signature should be present and valid
|
// Signature should be present and valid
|
||||||
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
assert.NotEmpty(t, manifest.pbOuter.Signature)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifestTamperedSignatureFails(t *testing.T) {
|
func TestManifestTamperedSignatureFails(t *testing.T) {
|
||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, cleanup := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
defer cleanup()
|
||||||
|
|
||||||
// Create a signed manifest
|
// Create a signed manifest
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
@@ -344,12 +299,11 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
|
|||||||
|
|
||||||
content := []byte("test file content")
|
content := []byte("test file content")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
err = b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Tamper with the signature by replacing some bytes
|
// Tamper with the signature by replacing some bytes
|
||||||
@@ -358,7 +312,6 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
|
|||||||
for i := range data {
|
for i := range data {
|
||||||
if i > 100 && data[i] == 'A' {
|
if i > 100 && data[i] == 'A' {
|
||||||
data[i] = 'B'
|
data[i] = 'B'
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,21 +322,18 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuilderWithoutSigning(t *testing.T) {
|
func TestBuilderWithoutSigning(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Create a builder without signing options
|
// Create a builder without signing options
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
|
|
||||||
// Add a test file
|
// Add a test file
|
||||||
content := []byte("test file content")
|
content := []byte("test file content")
|
||||||
reader := bytes.NewReader(content)
|
reader := bytes.NewReader(content)
|
||||||
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Build the manifest
|
// Build the manifest
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
err = b.Build(context.Background(), &buf)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse the manifest and verify signature fields are empty
|
// Parse the manifest and verify signature fields are empty
|
||||||
@@ -391,98 +341,7 @@ func TestBuilderWithoutSigning(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, manifest.pbOuter)
|
require.NotNil(t, manifest.pbOuter)
|
||||||
|
|
||||||
assert.Empty(t, manifest.pbOuter.GetSignature(),
|
assert.Empty(t, manifest.pbOuter.Signature, "signature should be empty when not signing")
|
||||||
"signature should be empty when not signing")
|
assert.Empty(t, manifest.pbOuter.Signer, "signer should be empty when not signing")
|
||||||
assert.Empty(t, manifest.pbOuter.GetSigner(),
|
assert.Empty(t, manifest.pbOuter.SigningPubKey, "signing public key should be empty when not signing")
|
||||||
"signer should be empty when not signing")
|
|
||||||
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
||||||
"signing public key should be empty when not signing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeGPGPath writes script as an executable named gpg into a temporary
|
|
||||||
// directory and returns a PATH value with that directory first.
|
|
||||||
func fakeGPGPath(t *testing.T, script string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
binDir := t.TempDir()
|
|
||||||
//nolint:gosec // G306: the fake gpg has to be executable
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, "gpg"),
|
|
||||||
[]byte(script), 0o700))
|
|
||||||
|
|
||||||
return binDir + string(os.PathListSeparator) + os.Getenv("PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGPGTimeoutKillsGPG puts a fake gpg that never finishes first on
|
|
||||||
// PATH and checks that a run past its deadline is killed and reported as
|
|
||||||
// a timeout of the named operation, instead of hanging.
|
|
||||||
func TestGPGTimeoutKillsGPG(t *testing.T) {
|
|
||||||
t.Setenv("PATH", fakeGPGPath(t, "#!/bin/sh\nexec sleep 10\n"))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
||||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
||||||
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGPGCancelWhenChildHoldsOutput uses a fake gpg that runs sleep as a
|
|
||||||
// child instead of exec-ing it, the way a wrapper script around the real
|
|
||||||
// gpg might. Killing the fake gpg leaves sleep holding its stdout and
|
|
||||||
// stderr open; the call must still return once ctx ends instead of waiting
|
|
||||||
// for sleep to exit. The fake gpg writes the process ID of sleep to a named
|
|
||||||
// pipe; the test ends ctx only after reading it, so sleep is running by
|
|
||||||
// then, and kills sleep before returning.
|
|
||||||
func TestGPGCancelWhenChildHoldsOutput(t *testing.T) {
|
|
||||||
pidPipe := filepath.Join(t.TempDir(), "sleep.pid")
|
|
||||||
require.NoError(t, syscall.Mkfifo(pidPipe, 0o600))
|
|
||||||
// sleep outlasts the 10 s wait below, so a call that waits for it fails.
|
|
||||||
t.Setenv("PATH", fakeGPGPath(t,
|
|
||||||
"#!/bin/sh\nsleep 60 &\necho $! >'"+pidPipe+"'\nwait\n"))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
signErr := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
||||||
signErr <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
pid, err := os.ReadFile(pidPipe) //nolint:gosec // G304: path inside t.TempDir()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(string(pid)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sleep, err := os.FindProcess(n)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { require.NoError(t, sleep.Kill()) })
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
// The call should return about gpgWaitDelay (one second) after the
|
|
||||||
// cancel. 10 s is far above that and well under the 30 s test timeout,
|
|
||||||
// which would abort the whole package before the cleanup kills sleep.
|
|
||||||
select {
|
|
||||||
case err := <-signErr:
|
|
||||||
require.ErrorIs(t, err, context.Canceled)
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Fatal("the call waited for the child holding gpg's output to exit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg
|
|
||||||
// runs that sign a manifest through the context given to Build.
|
|
||||||
func TestBuildPassesContextToSigning(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
b.SetSigningOptions(&SigningOptions{KeyID: "any"})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-28
@@ -9,18 +9,9 @@ import (
|
|||||||
"github.com/multiformats/go-multihash"
|
"github.com/multiformats/go-multihash"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
errOuterNotSet = errors.New("pbOuter not set")
|
|
||||||
errUUIDNotSet = errors.New("UUID not set")
|
|
||||||
errSHA256NotSet = errors.New("SHA256 hash not set")
|
|
||||||
)
|
|
||||||
|
|
||||||
// manifest holds the internal representation of a manifest file.
|
// manifest holds the internal representation of a manifest file.
|
||||||
// Use NewManifestFromFile or NewManifestFromReader to load an existing
|
// Use NewManifestFromFile or NewManifestFromReader to load an existing manifest,
|
||||||
// manifest, or use Builder to create a new one.
|
// or use Builder to create a new one.
|
||||||
//
|
|
||||||
// Whether this type should be exported is an open design question owned by
|
|
||||||
// the repository owner; see README design question 13.
|
|
||||||
type manifest struct {
|
type manifest struct {
|
||||||
pbInner *MFFile
|
pbInner *MFFile
|
||||||
pbOuter *MFFileOuter
|
pbOuter *MFFileOuter
|
||||||
@@ -32,9 +23,8 @@ type manifest struct {
|
|||||||
func (m *manifest) String() string {
|
func (m *manifest) String() string {
|
||||||
count := 0
|
count := 0
|
||||||
if m.pbInner != nil {
|
if m.pbInner != nil {
|
||||||
count = len(m.pbInner.GetFiles())
|
count = len(m.pbInner.Files)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("<Manifest count=%d>", count)
|
return fmt.Sprintf("<Manifest count=%d>", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +33,7 @@ func (m *manifest) Files() []*MFFilePath {
|
|||||||
if m.pbInner == nil {
|
if m.pbInner == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return m.pbInner.Files
|
||||||
return m.pbInner.GetFiles()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// signatureString generates the canonical string used for signing/verification.
|
// signatureString generates the canonical string used for signing/verification.
|
||||||
@@ -52,24 +41,20 @@ func (m *manifest) Files() []*MFFilePath {
|
|||||||
// Requires pbOuter to be set with Uuid and Sha256 fields.
|
// Requires pbOuter to be set with Uuid and Sha256 fields.
|
||||||
func (m *manifest) signatureString() (string, error) {
|
func (m *manifest) signatureString() (string, error) {
|
||||||
if m.pbOuter == nil {
|
if m.pbOuter == nil {
|
||||||
return "", errOuterNotSet
|
return "", errors.New("pbOuter not set")
|
||||||
|
}
|
||||||
|
if len(m.pbOuter.Uuid) == 0 {
|
||||||
|
return "", errors.New("UUID not set")
|
||||||
|
}
|
||||||
|
if len(m.pbOuter.Sha256) == 0 {
|
||||||
|
return "", errors.New("SHA256 hash not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.pbOuter.GetUuid()) == 0 {
|
mh, err := multihash.Encode(m.pbOuter.Sha256, multihash.SHA2_256)
|
||||||
return "", errUUIDNotSet
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(m.pbOuter.GetSha256()) == 0 {
|
|
||||||
return "", errSHA256NotSet
|
|
||||||
}
|
|
||||||
|
|
||||||
mh, err := multihash.Encode(m.pbOuter.GetSha256(), multihash.SHA2_256)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to encode multihash: %w", err)
|
return "", fmt.Errorf("failed to encode multihash: %w", err)
|
||||||
}
|
}
|
||||||
|
uuidStr := hex.EncodeToString(m.pbOuter.Uuid)
|
||||||
uuidStr := hex.EncodeToString(m.pbOuter.GetUuid())
|
|
||||||
mhStr := hex.EncodeToString(mh)
|
mhStr := hex.EncodeToString(mh)
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s-%s", MAGIC, uuidStr, mhStr), nil
|
return fmt.Sprintf("%s-%s-%s", MAGIC, uuidStr, mhStr), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
//go:generate protoc ./mf.proto --go_out=paths=source_relative:.
|
||||||
+10
-20
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v6.33.4
|
||||||
// source: mf.proto
|
// source: mf.proto
|
||||||
|
|
||||||
@@ -223,11 +223,11 @@ type MFFileOuter struct {
|
|||||||
// uuid must match the uuid in the inner message
|
// uuid must match the uuid in the inner message
|
||||||
Uuid []byte `protobuf:"bytes,105,opt,name=uuid,proto3" json:"uuid,omitempty"`
|
Uuid []byte `protobuf:"bytes,105,opt,name=uuid,proto3" json:"uuid,omitempty"`
|
||||||
InnerMessage []byte `protobuf:"bytes,199,opt,name=innerMessage,proto3" json:"innerMessage,omitempty"`
|
InnerMessage []byte `protobuf:"bytes,199,opt,name=innerMessage,proto3" json:"innerMessage,omitempty"`
|
||||||
//detached signature, ascii or binary
|
// detached signature, ascii or binary
|
||||||
Signature []byte `protobuf:"bytes,201,opt,name=signature,proto3,oneof" json:"signature,omitempty"`
|
Signature []byte `protobuf:"bytes,201,opt,name=signature,proto3,oneof" json:"signature,omitempty"`
|
||||||
//full GPG key id
|
// full GPG key id
|
||||||
Signer []byte `protobuf:"bytes,202,opt,name=signer,proto3,oneof" json:"signer,omitempty"`
|
Signer []byte `protobuf:"bytes,202,opt,name=signer,proto3,oneof" json:"signer,omitempty"`
|
||||||
//full GPG signing public key, ascii or binary
|
// full GPG signing public key, ascii or binary
|
||||||
SigningPubKey []byte `protobuf:"bytes,203,opt,name=signingPubKey,proto3,oneof" json:"signingPubKey,omitempty"`
|
SigningPubKey []byte `protobuf:"bytes,203,opt,name=signingPubKey,proto3,oneof" json:"signingPubKey,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -337,11 +337,9 @@ type MFFilePath struct {
|
|||||||
// gotta have at least one:
|
// gotta have at least one:
|
||||||
Hashes []*MFFileChecksum `protobuf:"bytes,3,rep,name=hashes,proto3" json:"hashes,omitempty"`
|
Hashes []*MFFileChecksum `protobuf:"bytes,3,rep,name=hashes,proto3" json:"hashes,omitempty"`
|
||||||
// optional per-file metadata
|
// optional per-file metadata
|
||||||
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
|
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
|
||||||
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
|
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
|
||||||
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
|
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
|
||||||
// permission bits, at most 0777; 0 when not recorded
|
|
||||||
Mode uint32 `protobuf:"varint,304,opt,name=mode,proto3" json:"mode,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -418,13 +416,6 @@ func (x *MFFilePath) GetCtime() *Timestamp {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *MFFilePath) GetMode() uint32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.Mode
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type MFFileChecksum struct {
|
type MFFileChecksum struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// 1.0 golang implementation must write a multihash here
|
// 1.0 golang implementation must write a multihash here
|
||||||
@@ -570,7 +561,7 @@ const file_mf_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"_signatureB\t\n" +
|
"_signatureB\t\n" +
|
||||||
"\a_signerB\x10\n" +
|
"\a_signerB\x10\n" +
|
||||||
"\x0e_signingPubKey\"\x85\x02\n" +
|
"\x0e_signingPubKey\"\xf0\x01\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"MFFilePath\x12\x12\n" +
|
"MFFilePath\x12\x12\n" +
|
||||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
|
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
|
||||||
@@ -580,8 +571,7 @@ const file_mf_proto_rawDesc = "" +
|
|||||||
"\x05mtime\x18\xae\x02 \x01(\v2\n" +
|
"\x05mtime\x18\xae\x02 \x01(\v2\n" +
|
||||||
".TimestampH\x01R\x05mtime\x88\x01\x01\x12&\n" +
|
".TimestampH\x01R\x05mtime\x88\x01\x01\x12&\n" +
|
||||||
"\x05ctime\x18\xaf\x02 \x01(\v2\n" +
|
"\x05ctime\x18\xaf\x02 \x01(\v2\n" +
|
||||||
".TimestampH\x02R\x05ctime\x88\x01\x01\x12\x13\n" +
|
".TimestampH\x02R\x05ctime\x88\x01\x01B\v\n" +
|
||||||
"\x04mode\x18\xb0\x02 \x01(\rR\x04modeB\v\n" +
|
|
||||||
"\t_mimeTypeB\b\n" +
|
"\t_mimeTypeB\b\n" +
|
||||||
"\x06_mtimeB\b\n" +
|
"\x06_mtimeB\b\n" +
|
||||||
"\x06_ctime\".\n" +
|
"\x06_ctime\".\n" +
|
||||||
@@ -597,7 +587,7 @@ const file_mf_proto_rawDesc = "" +
|
|||||||
"\fVERSION_NONE\x10\x00\x12\x0f\n" +
|
"\fVERSION_NONE\x10\x00\x12\x0f\n" +
|
||||||
"\vVERSION_ONE\x10\x01B\f\n" +
|
"\vVERSION_ONE\x10\x01B\f\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"_createdAtB\x1bZ\x19sneak.berlin/go/mfer/mferb\x06proto3"
|
"_createdAtB\x1dZ\x1bgit.eeqj.de/sneak/mfer/mferb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mf_proto_rawDescOnce sync.Once
|
file_mf_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,6 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
option go_package = "sneak.berlin/go/mfer/mfer";
|
option go_package = "git.eeqj.de/sneak/mfer/mfer";
|
||||||
|
|
||||||
message Timestamp {
|
message Timestamp {
|
||||||
int64 seconds = 1;
|
int64 seconds = 1;
|
||||||
@@ -59,8 +59,6 @@ message MFFilePath {
|
|||||||
optional string mimeType = 301;
|
optional string mimeType = 301;
|
||||||
optional Timestamp mtime = 302;
|
optional Timestamp mtime = 302;
|
||||||
optional Timestamp ctime = 303;
|
optional Timestamp ctime = 303;
|
||||||
// permission bits, at most 0777; 0 when not recorded
|
|
||||||
uint32 mode = 304;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message MFFileChecksum {
|
message MFFileChecksum {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3d4dcb0b2f4640dd3ae6bb48b833e9f26ae9771e2d3e88bd646e7f1724dda654 mf.proto
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package mfer_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/reflect/protoreflect"
|
|
||||||
"sneak.berlin/go/mfer/mfer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mf.pb.go is generated from mf.proto and committed. `make generate`
|
|
||||||
// records the hash of the mf.proto it generated from in mf.proto.sha256.
|
|
||||||
func TestGeneratedCodeMatchesProto(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
proto, err := os.ReadFile("mf.proto")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
recorded, err := os.ReadFile("mf.proto.sha256")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
recordedHash, _, _ := strings.Cut(string(recorded), " ")
|
|
||||||
sum := sha256.Sum256(proto)
|
|
||||||
require.Equal(t, recordedHash, hex.EncodeToString(sum[:]),
|
|
||||||
"mfer/mf.proto has changed since mfer/mf.pb.go was generated "+
|
|
||||||
"from it: run `make generate` and commit the result")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file entry has exactly the fields docs/FORMAT.md lists for MFFilePath.
|
|
||||||
func TestFileEntryFieldsMatchSpec(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
want := map[string]protoreflect.FieldNumber{
|
|
||||||
"path": 1,
|
|
||||||
"size": 2,
|
|
||||||
"hashes": 3,
|
|
||||||
"mimeType": 301,
|
|
||||||
"mtime": 302,
|
|
||||||
"ctime": 303,
|
|
||||||
"mode": 304,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := map[string]protoreflect.FieldNumber{}
|
|
||||||
|
|
||||||
fields := (&mfer.MFFilePath{}).ProtoReflect().Descriptor().Fields()
|
|
||||||
for i := range fields.Len() {
|
|
||||||
got[string(fields.Get(i).Name())] = fields.Get(i).Number()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
+160
-344
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -44,25 +43,12 @@ type ScanStatus struct {
|
|||||||
|
|
||||||
// ScannerOptions configures scanner behavior.
|
// ScannerOptions configures scanner behavior.
|
||||||
type ScannerOptions struct {
|
type ScannerOptions struct {
|
||||||
// IncludeDotfiles includes files and directories starting with a dot
|
IncludeDotfiles bool // Include files and directories starting with a dot (default: exclude)
|
||||||
// (default: exclude).
|
FollowSymLinks bool // Resolve symlinks instead of skipping them
|
||||||
IncludeDotfiles bool
|
IncludeTimestamps bool // Include createdAt timestamp in manifest (default: omit for determinism)
|
||||||
// FollowSymLinks resolves symlinks instead of skipping them.
|
Fs afero.Fs // Filesystem to use, defaults to OsFs if nil
|
||||||
FollowSymLinks bool
|
SigningOptions *SigningOptions // GPG signing options (nil = no signing)
|
||||||
// IncludeTimestamps includes a createdAt timestamp in the manifest
|
Seed string // If set, derive a deterministic UUID from this seed
|
||||||
// (default: omit for determinism).
|
|
||||||
IncludeTimestamps bool
|
|
||||||
// IncludePermissions records each file's permission bits, 0777 at
|
|
||||||
// most, in the manifest (default: record 0000).
|
|
||||||
IncludePermissions bool
|
|
||||||
// Fs is the filesystem to use, defaults to OsFs if nil.
|
|
||||||
Fs afero.Fs
|
|
||||||
// SigningOptions holds GPG signing options (nil = no signing).
|
|
||||||
SigningOptions *SigningOptions
|
|
||||||
// Seed, if set, derives a deterministic UUID from this seed.
|
|
||||||
Seed string
|
|
||||||
// ExcludePaths lists files to leave out of the listing, matched with os.SameFile.
|
|
||||||
ExcludePaths []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileEntry represents a file that has been enumerated.
|
// FileEntry represents a file that has been enumerated.
|
||||||
@@ -72,7 +58,6 @@ type FileEntry struct {
|
|||||||
Size FileSize // File size in bytes
|
Size FileSize // File size in bytes
|
||||||
Mtime ModTime // Last modification time
|
Mtime ModTime // Last modification time
|
||||||
Ctime time.Time // Creation time (platform-dependent)
|
Ctime time.Time // Creation time (platform-dependent)
|
||||||
Mode fs.FileMode // Permission bits (Perm() of the file's mode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanner accumulates files and generates manifests from them.
|
// Scanner accumulates files and generates manifests from them.
|
||||||
@@ -82,7 +67,6 @@ type Scanner struct {
|
|||||||
totalBytes FileSize // cached sum of all file sizes
|
totalBytes FileSize // cached sum of all file sizes
|
||||||
options *ScannerOptions
|
options *ScannerOptions
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
excluded []fs.FileInfo // the files named in ExcludePaths that exist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner creates a new Scanner with default options.
|
// NewScanner creates a new Scanner with default options.
|
||||||
@@ -95,28 +79,15 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
|
|||||||
if opts == nil {
|
if opts == nil {
|
||||||
opts = &ScannerOptions{}
|
opts = &ScannerOptions{}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs := opts.Fs
|
fs := opts.Fs
|
||||||
if fs == nil {
|
if fs == nil {
|
||||||
fs = afero.NewOsFs()
|
fs = afero.NewOsFs()
|
||||||
}
|
}
|
||||||
|
return &Scanner{
|
||||||
s := &Scanner{
|
|
||||||
files: make([]*FileEntry, 0),
|
files: make([]*FileEntry, 0),
|
||||||
options: opts,
|
options: opts,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A path that cannot be stat'd, normally because no file is there,
|
|
||||||
// needs no leaving out.
|
|
||||||
for _, p := range opts.ExcludePaths {
|
|
||||||
info, err := s.fs.Stat(p)
|
|
||||||
if err == nil {
|
|
||||||
s.excluded = append(s.excluded, info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumerateFile adds a single file to the scanner, calling stat() to get metadata.
|
// EnumerateFile adds a single file to the scanner, calling stat() to get metadata.
|
||||||
@@ -125,63 +96,47 @@ func (s *Scanner) EnumerateFile(filePath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := s.fs.Stat(abs)
|
info, err := s.fs.Stat(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For single files, use the filename as the relative path
|
// For single files, use the filename as the relative path
|
||||||
basePath := filepath.Dir(abs)
|
basePath := filepath.Dir(abs)
|
||||||
|
|
||||||
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumeratePath walks a directory path and adds all files to the scanner.
|
// EnumeratePath walks a directory path and adds all files to the scanner.
|
||||||
// If progress is non-nil, status updates are sent as files are discovered.
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
// The progress channel is closed when the method returns.
|
// The progress channel is closed when the method returns.
|
||||||
func (s *Scanner) EnumeratePath(
|
func (s *Scanner) EnumeratePath(inputPath string, progress chan<- EnumerateStatus) error {
|
||||||
inputPath string,
|
|
||||||
progress chan<- EnumerateStatus,
|
|
||||||
) error {
|
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
defer close(progress)
|
defer close(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
abs, err := filepath.Abs(inputPath)
|
abs, err := filepath.Abs(inputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
||||||
|
|
||||||
return s.enumerateFS(afs, abs, progress)
|
return s.enumerateFS(afs, abs, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumeratePaths walks multiple directory paths and adds all files to the scanner.
|
// EnumeratePaths walks multiple directory paths and adds all files to the scanner.
|
||||||
// If progress is non-nil, status updates are sent as files are discovered.
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
// The progress channel is closed when the method returns.
|
// The progress channel is closed when the method returns.
|
||||||
func (s *Scanner) EnumeratePaths(
|
func (s *Scanner) EnumeratePaths(progress chan<- EnumerateStatus, inputPaths ...string) error {
|
||||||
progress chan<- EnumerateStatus,
|
|
||||||
inputPaths ...string,
|
|
||||||
) error {
|
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
defer close(progress)
|
defer close(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range inputPaths {
|
for _, p := range inputPaths {
|
||||||
abs, err := filepath.Abs(p)
|
abs, err := filepath.Abs(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
||||||
|
if err := s.enumerateFS(afs, abs, progress); err != nil {
|
||||||
err = s.enumerateFS(afs, abs, progress)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,237 +144,31 @@ func (s *Scanner) EnumeratePaths(
|
|||||||
// If progress is non-nil, status updates are sent as files are discovered.
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
// The progress channel is closed when the method returns.
|
// The progress channel is closed when the method returns.
|
||||||
// basePath is used to compute absolute paths for file reading.
|
// basePath is used to compute absolute paths for file reading.
|
||||||
func (s *Scanner) EnumerateFS(
|
func (s *Scanner) EnumerateFS(afs afero.Fs, basePath string, progress chan<- EnumerateStatus) error {
|
||||||
afs afero.Fs,
|
|
||||||
basePath string,
|
|
||||||
progress chan<- EnumerateStatus,
|
|
||||||
) error {
|
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
defer close(progress)
|
defer close(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.enumerateFS(afs, basePath, progress)
|
return s.enumerateFS(afs, basePath, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files returns a copy of all files added to the scanner.
|
// enumerateFS is the internal implementation that doesn't close the progress channel.
|
||||||
func (s *Scanner) Files() []*FileEntry {
|
func (s *Scanner) enumerateFS(afs afero.Fs, basePath string, progress chan<- EnumerateStatus) error {
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
out := make([]*FileEntry, len(s.files))
|
|
||||||
copy(out, s.files)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileCount returns the number of files in the scanner.
|
|
||||||
func (s *Scanner) FileCount() FileCount {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
return FileCount(len(s.files))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TotalBytes returns the total size of all files in the scanner.
|
|
||||||
func (s *Scanner) TotalBytes() FileSize {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
return s.totalBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToManifest reads all file contents, computes hashes, and generates a manifest.
|
|
||||||
// If progress is non-nil, status updates are sent approximately once per second.
|
|
||||||
// The progress channel is closed when the method returns.
|
|
||||||
// The manifest is written to the provided io.Writer.
|
|
||||||
func (s *Scanner) ToManifest(
|
|
||||||
ctx context.Context, w io.Writer, progress chan<- ScanStatus,
|
|
||||||
) error {
|
|
||||||
if progress != nil {
|
|
||||||
defer close(progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.RLock()
|
|
||||||
files := make([]*FileEntry, len(s.files))
|
|
||||||
copy(files, s.files)
|
|
||||||
totalFiles := FileCount(len(files))
|
|
||||||
|
|
||||||
var totalBytes FileSize
|
|
||||||
for _, f := range files {
|
|
||||||
totalBytes += f.Size
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
builder := s.configureBuilder()
|
|
||||||
|
|
||||||
var (
|
|
||||||
scannedFiles FileCount
|
|
||||||
scannedBytes FileSize
|
|
||||||
)
|
|
||||||
|
|
||||||
lastProgressTime := time.Now()
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
pt := &scanProgressTracker{
|
|
||||||
progress: progress,
|
|
||||||
totalFiles: totalFiles,
|
|
||||||
totalBytes: totalBytes,
|
|
||||||
startTime: startTime,
|
|
||||||
lastProgress: &lastProgressTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range files {
|
|
||||||
// Check for cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesRead, err := s.scanFile(builder, pt, entry, scannedFiles, scannedBytes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
scannedFiles++
|
|
||||||
scannedBytes += bytesRead
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send final progress (ETA is 0 at completion; remaining bytes are 0,
|
|
||||||
// so computeRateETA yields eta 0 and the same average rate as before)
|
|
||||||
if progress != nil {
|
|
||||||
rate, _ := computeRateETA(time.Since(startTime), scannedBytes, totalBytes)
|
|
||||||
|
|
||||||
sendScanStatus(progress, ScanStatus{
|
|
||||||
TotalFiles: totalFiles,
|
|
||||||
ScannedFiles: scannedFiles,
|
|
||||||
TotalBytes: totalBytes,
|
|
||||||
ScannedBytes: scannedBytes,
|
|
||||||
BytesPerSec: rate,
|
|
||||||
ETA: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build and write manifest
|
|
||||||
return builder.Build(ctx, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// configureBuilder constructs a manifest builder configured from the
|
|
||||||
// scanner options.
|
|
||||||
func (s *Scanner) configureBuilder() *Builder {
|
|
||||||
builder := NewBuilder()
|
|
||||||
if s.options.IncludeTimestamps {
|
|
||||||
builder.SetIncludeTimestamps(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.options.SigningOptions != nil {
|
|
||||||
builder.SetSigningOptions(s.options.SigningOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.options.Seed != "" {
|
|
||||||
builder.SetSeed(s.options.Seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanFile hashes a single file into the builder, forwarding per-file
|
|
||||||
// progress updates, and returns the number of bytes read.
|
|
||||||
func (s *Scanner) scanFile(
|
|
||||||
builder *Builder,
|
|
||||||
pt *scanProgressTracker,
|
|
||||||
entry *FileEntry,
|
|
||||||
scannedFiles FileCount,
|
|
||||||
scannedBytes FileSize,
|
|
||||||
) (FileSize, error) {
|
|
||||||
// Open file
|
|
||||||
f, err := s.fs.Open(string(entry.AbsPath))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create progress channel for this file
|
|
||||||
var (
|
|
||||||
fileProgress chan FileHashProgress
|
|
||||||
wg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
if pt.progress != nil {
|
|
||||||
fileProgress = make(chan FileHashProgress, 1)
|
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
go func(base FileSize, done FileCount) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
pt.forward(fileProgress, done, base)
|
|
||||||
}(scannedBytes, scannedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A mode of 0 records 0000, which means none was recorded.
|
|
||||||
var mode fs.FileMode
|
|
||||||
if s.options.IncludePermissions {
|
|
||||||
mode = entry.Mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to manifest with progress channel
|
|
||||||
bytesRead, err := builder.AddFile(
|
|
||||||
entry.Path,
|
|
||||||
entry.Size,
|
|
||||||
entry.Mtime,
|
|
||||||
mode,
|
|
||||||
f,
|
|
||||||
fileProgress,
|
|
||||||
)
|
|
||||||
_ = f.Close()
|
|
||||||
|
|
||||||
// Close channel and wait for goroutine to finish
|
|
||||||
if fileProgress != nil {
|
|
||||||
close(fileProgress)
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Verbosef("+ %s (%s)", entry.Path, humanize.IBytes(sizeToUint64(bytesRead)))
|
|
||||||
|
|
||||||
return bytesRead, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// enumerateFS is the internal implementation that doesn't close the
|
|
||||||
// progress channel.
|
|
||||||
func (s *Scanner) enumerateFS(
|
|
||||||
afs afero.Fs,
|
|
||||||
basePath string,
|
|
||||||
progress chan<- EnumerateStatus,
|
|
||||||
) error {
|
|
||||||
return afero.Walk(afs, "/", func(p string, info fs.FileInfo, err error) error {
|
return afero.Walk(afs, "/", func(p string, info fs.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.options.IncludeDotfiles && IsHiddenPath(p) {
|
if !s.options.IncludeDotfiles && IsHiddenPath(p) {
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.enumerateFileWithInfo(p, basePath, info, progress)
|
return s.enumerateFileWithInfo(p, basePath, info, progress)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// enumerateFileWithInfo adds a file with pre-existing fs.FileInfo.
|
// enumerateFileWithInfo adds a file with pre-existing fs.FileInfo.
|
||||||
func (s *Scanner) enumerateFileWithInfo(
|
func (s *Scanner) enumerateFileWithInfo(filePath string, basePath string, info fs.FileInfo, progress chan<- EnumerateStatus) error {
|
||||||
filePath string,
|
|
||||||
basePath string,
|
|
||||||
info fs.FileInfo,
|
|
||||||
progress chan<- EnumerateStatus,
|
|
||||||
) error {
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
// Manifests contain only files, directories are implied
|
// Manifests contain only files, directories are implied
|
||||||
return nil
|
return nil
|
||||||
@@ -444,13 +193,11 @@ func (s *Scanner) enumerateFileWithInfo(
|
|||||||
realPath, err := filepath.EvalSymlinks(absPath)
|
realPath, err := filepath.EvalSymlinks(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Skip broken symlinks
|
// Skip broken symlinks
|
||||||
return nil //nolint:nilerr // broken symlinks are skipped by design
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
realInfo, err := s.fs.Stat(realPath)
|
realInfo, err := s.fs.Stat(realPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Skip symlinks whose target cannot be stat'd
|
return nil
|
||||||
return nil //nolint:nilerr // unreadable targets are skipped by design
|
|
||||||
}
|
}
|
||||||
// Skip if symlink points to a directory
|
// Skip if symlink points to a directory
|
||||||
if realInfo.IsDir() {
|
if realInfo.IsDir() {
|
||||||
@@ -461,18 +208,11 @@ func (s *Scanner) enumerateFileWithInfo(
|
|||||||
info = realInfo
|
info = realInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, excluded := range s.excluded {
|
|
||||||
if os.SameFile(info, excluded) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := &FileEntry{
|
entry := &FileEntry{
|
||||||
Path: RelFilePath(cleanPath),
|
Path: RelFilePath(cleanPath),
|
||||||
AbsPath: AbsFilePath(absPath),
|
AbsPath: AbsFilePath(absPath),
|
||||||
Size: FileSize(info.Size()),
|
Size: FileSize(info.Size()),
|
||||||
Mtime: ModTime(info.ModTime()),
|
Mtime: ModTime(info.ModTime()),
|
||||||
Mode: info.Mode().Perm(),
|
|
||||||
// Note: Ctime not available from fs.FileInfo on all platforms
|
// Note: Ctime not available from fs.FileInfo on all platforms
|
||||||
// Will need platform-specific code to extract it
|
// Will need platform-specific code to extract it
|
||||||
}
|
}
|
||||||
@@ -492,78 +232,160 @@ func (s *Scanner) enumerateFileWithInfo(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanProgressTracker carries the shared state needed to report rate-limited
|
// Files returns a copy of all files added to the scanner.
|
||||||
// scan progress updates.
|
func (s *Scanner) Files() []*FileEntry {
|
||||||
type scanProgressTracker struct {
|
s.mu.RLock()
|
||||||
progress chan<- ScanStatus
|
defer s.mu.RUnlock()
|
||||||
totalFiles FileCount
|
out := make([]*FileEntry, len(s.files))
|
||||||
totalBytes FileSize
|
copy(out, s.files)
|
||||||
startTime time.Time
|
return out
|
||||||
lastProgress *time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// forward relays per-file hash progress to the scan progress channel,
|
// FileCount returns the number of files in the scanner.
|
||||||
// rate-limited to one update per second.
|
func (s *Scanner) FileCount() FileCount {
|
||||||
func (pt *scanProgressTracker) forward(
|
s.mu.RLock()
|
||||||
fileProgress <-chan FileHashProgress,
|
defer s.mu.RUnlock()
|
||||||
scannedFiles FileCount,
|
return FileCount(len(s.files))
|
||||||
baseBytes FileSize,
|
}
|
||||||
) {
|
|
||||||
for p := range fileProgress {
|
// TotalBytes returns the total size of all files in the scanner.
|
||||||
// Send progress at most once per second
|
func (s *Scanner) TotalBytes() FileSize {
|
||||||
now := time.Now()
|
s.mu.RLock()
|
||||||
if now.Sub(*pt.lastProgress) < time.Second {
|
defer s.mu.RUnlock()
|
||||||
continue
|
return s.totalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToManifest reads all file contents, computes hashes, and generates a manifest.
|
||||||
|
// If progress is non-nil, status updates are sent approximately once per second.
|
||||||
|
// The progress channel is closed when the method returns.
|
||||||
|
// The manifest is written to the provided io.Writer.
|
||||||
|
func (s *Scanner) ToManifest(ctx context.Context, w io.Writer, progress chan<- ScanStatus) error {
|
||||||
|
if progress != nil {
|
||||||
|
defer close(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
files := make([]*FileEntry, len(s.files))
|
||||||
|
copy(files, s.files)
|
||||||
|
totalFiles := FileCount(len(files))
|
||||||
|
var totalBytes FileSize
|
||||||
|
for _, f := range files {
|
||||||
|
totalBytes += f.Size
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
builder := NewBuilder()
|
||||||
|
if s.options.IncludeTimestamps {
|
||||||
|
builder.SetIncludeTimestamps(true)
|
||||||
|
}
|
||||||
|
if s.options.SigningOptions != nil {
|
||||||
|
builder.SetSigningOptions(s.options.SigningOptions)
|
||||||
|
}
|
||||||
|
if s.options.Seed != "" {
|
||||||
|
builder.SetSeed(s.options.Seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scannedFiles FileCount
|
||||||
|
var scannedBytes FileSize
|
||||||
|
lastProgressTime := time.Now()
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
for _, entry := range files {
|
||||||
|
// Check for cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
currentBytes := baseBytes + p.BytesRead
|
// Open file
|
||||||
rate, eta := computeRateETA(now.Sub(pt.startTime), currentBytes, pt.totalBytes)
|
f, err := s.fs.Open(string(entry.AbsPath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
sendScanStatus(pt.progress, ScanStatus{
|
// Create progress channel for this file
|
||||||
TotalFiles: pt.totalFiles,
|
var fileProgress chan FileHashProgress
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
if progress != nil {
|
||||||
|
fileProgress = make(chan FileHashProgress, 1)
|
||||||
|
wg.Add(1)
|
||||||
|
go func(baseScannedBytes FileSize) {
|
||||||
|
defer wg.Done()
|
||||||
|
for p := range fileProgress {
|
||||||
|
// Send progress at most once per second
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(lastProgressTime) >= time.Second {
|
||||||
|
elapsed := now.Sub(startTime).Seconds()
|
||||||
|
currentBytes := baseScannedBytes + p.BytesRead
|
||||||
|
var rate float64
|
||||||
|
var eta time.Duration
|
||||||
|
if elapsed > 0 && currentBytes > 0 {
|
||||||
|
rate = float64(currentBytes) / elapsed
|
||||||
|
remainingBytes := totalBytes - currentBytes
|
||||||
|
if rate > 0 {
|
||||||
|
eta = time.Duration(float64(remainingBytes)/rate) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendScanStatus(progress, ScanStatus{
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
ScannedFiles: scannedFiles,
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
ScannedBytes: currentBytes,
|
||||||
|
BytesPerSec: rate,
|
||||||
|
ETA: eta,
|
||||||
|
})
|
||||||
|
lastProgressTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(scannedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to manifest with progress channel
|
||||||
|
bytesRead, err := builder.AddFile(
|
||||||
|
entry.Path,
|
||||||
|
entry.Size,
|
||||||
|
entry.Mtime,
|
||||||
|
f,
|
||||||
|
fileProgress,
|
||||||
|
)
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
// Close channel and wait for goroutine to finish
|
||||||
|
if fileProgress != nil {
|
||||||
|
close(fileProgress)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Verbosef("+ %s (%s)", entry.Path, humanize.IBytes(uint64(bytesRead)))
|
||||||
|
|
||||||
|
scannedFiles++
|
||||||
|
scannedBytes += bytesRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send final progress (ETA is 0 at completion)
|
||||||
|
if progress != nil {
|
||||||
|
elapsed := time.Since(startTime).Seconds()
|
||||||
|
var rate float64
|
||||||
|
if elapsed > 0 {
|
||||||
|
rate = float64(scannedBytes) / elapsed
|
||||||
|
}
|
||||||
|
sendScanStatus(progress, ScanStatus{
|
||||||
|
TotalFiles: totalFiles,
|
||||||
ScannedFiles: scannedFiles,
|
ScannedFiles: scannedFiles,
|
||||||
TotalBytes: pt.totalBytes,
|
TotalBytes: totalBytes,
|
||||||
ScannedBytes: currentBytes,
|
ScannedBytes: scannedBytes,
|
||||||
BytesPerSec: rate,
|
BytesPerSec: rate,
|
||||||
ETA: eta,
|
ETA: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
*pt.lastProgress = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeRateETA returns the average throughput over elapsed time and the
|
|
||||||
// estimated time to process the remaining bytes at that rate.
|
|
||||||
func computeRateETA(
|
|
||||||
elapsed time.Duration,
|
|
||||||
done FileSize,
|
|
||||||
total FileSize,
|
|
||||||
) (float64, time.Duration) {
|
|
||||||
var (
|
|
||||||
rate float64
|
|
||||||
eta time.Duration
|
|
||||||
)
|
|
||||||
|
|
||||||
if elapsed > 0 && done > 0 {
|
|
||||||
rate = float64(done) / elapsed.Seconds()
|
|
||||||
|
|
||||||
remaining := total - done
|
|
||||||
if rate > 0 {
|
|
||||||
eta = time.Duration(float64(remaining)/rate) * time.Second
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rate, eta
|
// Build and write manifest
|
||||||
}
|
return builder.Build(w)
|
||||||
|
|
||||||
// sizeToUint64 converts a FileSize to uint64 for display, clamping
|
|
||||||
// negative values to zero so the conversion cannot overflow.
|
|
||||||
func sizeToUint64(v FileSize) uint64 {
|
|
||||||
if v < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint64(v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsHiddenPath returns true if the path or any of its parent directories
|
// IsHiddenPath returns true if the path or any of its parent directories
|
||||||
@@ -574,21 +396,17 @@ func IsHiddenPath(p string) bool {
|
|||||||
if tp == "." || tp == "/" {
|
if tp == "." || tp == "/" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(tp, ".") {
|
if strings.HasPrefix(tp, ".") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
d, f := path.Split(tp)
|
d, f := path.Split(tp)
|
||||||
if strings.HasPrefix(f, ".") {
|
if strings.HasPrefix(f, ".") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if d == "" {
|
if d == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
tp = d[0 : len(d)-1] // trim trailing slash from dir
|
tp = d[0 : len(d)-1] // trim trailing slash from dir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,7 +417,6 @@ func sendEnumerateStatus(ch chan<- EnumerateStatus, status EnumerateStatus) {
|
|||||||
if ch == nil {
|
if ch == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case ch <- status:
|
case ch <- status:
|
||||||
default:
|
default:
|
||||||
@@ -613,7 +430,6 @@ func sendScanStatus(ch chan<- ScanStatus, status ScanStatus) {
|
|||||||
if ch == nil {
|
if ch == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case ch <- status:
|
case ch <- status:
|
||||||
default:
|
default:
|
||||||
|
|||||||
+44
-125
@@ -1,10 +1,8 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,8 +12,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewScanner(t *testing.T) {
|
func TestNewScanner(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := NewScanner()
|
s := NewScanner()
|
||||||
assert.NotNil(t, s)
|
assert.NotNil(t, s)
|
||||||
assert.Equal(t, FileCount(0), s.FileCount())
|
assert.Equal(t, FileCount(0), s.FileCount())
|
||||||
@@ -23,18 +19,12 @@ func TestNewScanner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewScannerWithOptions(t *testing.T) {
|
func TestNewScannerWithOptions(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("nil options", func(t *testing.T) {
|
t.Run("nil options", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := NewScannerWithOptions(nil)
|
s := NewScannerWithOptions(nil)
|
||||||
assert.NotNil(t, s)
|
assert.NotNil(t, s)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("with options", func(t *testing.T) {
|
t.Run("with options", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
opts := &ScannerOptions{
|
opts := &ScannerOptions{
|
||||||
IncludeDotfiles: true,
|
IncludeDotfiles: true,
|
||||||
@@ -47,8 +37,6 @@ func TestNewScannerWithOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerEnumerateFile(t *testing.T) {
|
func TestScannerEnumerateFile(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0o644))
|
||||||
|
|
||||||
@@ -66,8 +54,6 @@ func TestScannerEnumerateFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerEnumerateFileMissing(t *testing.T) {
|
func TestScannerEnumerateFileMissing(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
err := s.EnumerateFile("/nonexistent.txt")
|
err := s.EnumerateFile("/nonexistent.txt")
|
||||||
@@ -75,14 +61,11 @@ func TestScannerEnumerateFileMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerEnumeratePath(t *testing.T) {
|
func TestScannerEnumeratePath(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
||||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
||||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0o644))
|
|
||||||
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
err := s.EnumeratePath("/testdir", nil)
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
@@ -93,8 +76,6 @@ func TestScannerEnumeratePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
||||||
@@ -119,8 +100,6 @@ func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerEnumeratePaths(t *testing.T) {
|
func TestScannerEnumeratePaths(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
||||||
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
||||||
@@ -135,20 +114,13 @@ func TestScannerEnumeratePaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerExcludeDotfiles(t *testing.T) {
|
func TestScannerExcludeDotfiles(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0o755))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0o644))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0o644))
|
|
||||||
require.NoError(t,
|
|
||||||
afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0o644))
|
|
||||||
|
|
||||||
t.Run("exclude by default", func(t *testing.T) {
|
t.Run("exclude by default", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: false})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: false})
|
||||||
err := s.EnumeratePath("/testdir", nil)
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -159,8 +131,6 @@ func TestScannerExcludeDotfiles(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("include when enabled", func(t *testing.T) {
|
t.Run("include when enabled", func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: true})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: true})
|
||||||
err := s.EnumeratePath("/testdir", nil)
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -170,43 +140,34 @@ func TestScannerExcludeDotfiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerToManifest(t *testing.T) {
|
func TestScannerToManifest(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0o644))
|
||||||
require.NoError(t,
|
|
||||||
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0o644))
|
|
||||||
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
err := s.EnumeratePath("/testdir", nil)
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = s.ToManifest(context.Background(), &buf, nil)
|
err = s.ToManifest(context.Background(), &buf, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Manifest should have magic bytes
|
// Manifest should have magic bytes
|
||||||
assert.Positive(t, buf.Len())
|
assert.True(t, buf.Len() > 0)
|
||||||
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerToManifestWithProgress(t *testing.T) {
|
func TestScannerToManifestWithProgress(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0o644))
|
|
||||||
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
err := s.EnumeratePath("/testdir", nil)
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
progress := make(chan ScanStatus, 10)
|
progress := make(chan ScanStatus, 10)
|
||||||
|
|
||||||
err = s.ToManifest(context.Background(), &buf, progress)
|
err = s.ToManifest(context.Background(), &buf, progress)
|
||||||
@@ -227,15 +188,12 @@ func TestScannerToManifestWithProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerToManifestContextCancellation(t *testing.T) {
|
func TestScannerToManifestContextCancellation(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
// Create many files to ensure we have time to cancel
|
// Create many files to ensure we have time to cancel
|
||||||
for i := range 100 {
|
for i := 0; i < 100; i++ {
|
||||||
name := string(rune('a'+i%26)) + string(rune('0'+i/26)) + ".txt"
|
name := string(rune('a'+i%26)) + string(rune('0'+i/26)) + ".txt"
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/"+name, bytes.Repeat([]byte("x"), 100), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/"+name, bytes.Repeat([]byte("x"), 100), 0o644))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
@@ -246,30 +204,24 @@ func TestScannerToManifestContextCancellation(t *testing.T) {
|
|||||||
cancel() // Cancel immediately
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = s.ToManifest(ctx, &buf, nil)
|
err = s.ToManifest(ctx, &buf, nil)
|
||||||
assert.ErrorIs(t, err, context.Canceled)
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerToManifestEmptyScanner(t *testing.T) {
|
func TestScannerToManifestEmptyScanner(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err := s.ToManifest(context.Background(), &buf, nil)
|
err := s.ToManifest(context.Background(), &buf, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should still produce a valid manifest
|
// Should still produce a valid manifest
|
||||||
assert.Positive(t, buf.Len())
|
assert.True(t, buf.Len() > 0)
|
||||||
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerFilesCopiesSlice(t *testing.T) {
|
func TestScannerFilesCopiesSlice(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
@@ -284,13 +236,10 @@ func TestScannerFilesCopiesSlice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerEnumerateFS(t *testing.T) {
|
func TestScannerEnumerateFS(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755))
|
||||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0o644))
|
||||||
require.NoError(t,
|
require.NoError(t, afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0o644))
|
||||||
afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0o644))
|
|
||||||
|
|
||||||
// Create a basepath filesystem
|
// Create a basepath filesystem
|
||||||
baseFs := afero.NewBasePathFs(fs, "/testdir")
|
baseFs := afero.NewBasePathFs(fs, "/testdir")
|
||||||
@@ -303,37 +252,51 @@ func TestScannerEnumerateFS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSendEnumerateStatusNonBlocking(t *testing.T) {
|
func TestSendEnumerateStatusNonBlocking(t *testing.T) {
|
||||||
t.Parallel()
|
// Channel with no buffer - send should not block
|
||||||
|
|
||||||
// Nobody receives, so a blocking send would hang the test into its timeout.
|
|
||||||
ch := make(chan EnumerateStatus)
|
ch := make(chan EnumerateStatus)
|
||||||
|
|
||||||
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
|
// This should not block
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - did not block
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("sendEnumerateStatus blocked on full channel")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendScanStatusNonBlocking(t *testing.T) {
|
func TestSendScanStatusNonBlocking(t *testing.T) {
|
||||||
t.Parallel()
|
// Channel with no buffer - send should not block
|
||||||
|
|
||||||
// Nobody receives, so a blocking send would hang the test into its timeout.
|
|
||||||
ch := make(chan ScanStatus)
|
ch := make(chan ScanStatus)
|
||||||
|
|
||||||
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - did not block
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("sendScanStatus blocked on full channel")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendStatusNilChannel(t *testing.T) {
|
func TestSendStatusNilChannel(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Should not panic with nil channel
|
// Should not panic with nil channel
|
||||||
sendEnumerateStatus(nil, EnumerateStatus{})
|
sendEnumerateStatus(nil, EnumerateStatus{})
|
||||||
sendScanStatus(nil, ScanStatus{})
|
sendScanStatus(nil, ScanStatus{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScannerFileEntryFields(t *testing.T) {
|
func TestScannerFileEntryFields(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
now := time.Now().Truncate(time.Second)
|
now := time.Now().Truncate(time.Second)
|
||||||
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0o644))
|
||||||
require.NoError(t, fs.Chtimes("/test.txt", now, now))
|
require.NoError(t, fs.Chtimes("/test.txt", now, now))
|
||||||
|
|
||||||
@@ -351,54 +314,12 @@ func TestScannerFileEntryFields(t *testing.T) {
|
|||||||
assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second)
|
assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A manifest records every mode as 0000 unless the creator asks for
|
|
||||||
// permissions; then it records each file's permission bits and never the
|
|
||||||
// setuid, setgid or sticky bits.
|
|
||||||
func TestScannerRecordsModeOnlyWhenAsked(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/"+testFile1, []byte("a"), 0o640))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/run.sh", []byte("b"), 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, "/su", []byte("c"), 0o755))
|
|
||||||
require.NoError(t, fs.Chmod("/su", 0o755|os.ModeSetuid|os.ModeSetgid|os.ModeSticky))
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
includePermissions bool
|
|
||||||
want map[string]uint32
|
|
||||||
}{
|
|
||||||
{false, map[string]uint32{testFile1: 0, "run.sh": 0, "su": 0}},
|
|
||||||
{true, map[string]uint32{testFile1: 0o640, "run.sh": 0o755, "su": 0o755}},
|
|
||||||
} {
|
|
||||||
s := NewScannerWithOptions(&ScannerOptions{
|
|
||||||
Fs: fs,
|
|
||||||
IncludePermissions: tc.includePermissions,
|
|
||||||
})
|
|
||||||
require.NoError(t, s.EnumerateFS(fs, "/", nil))
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got := map[string]uint32{}
|
|
||||||
for _, f := range m.Files() {
|
|
||||||
got[f.GetPath()] = f.GetMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tc.want, got, "IncludePermissions: %v", tc.includePermissions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScannerLargeFileEnumeration(t *testing.T) {
|
func TestScannerLargeFileEnumeration(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
|
||||||
// Create 100 files
|
// Create 100 files
|
||||||
for i := range 100 {
|
for i := 0; i < 100; i++ {
|
||||||
name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt"
|
name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt"
|
||||||
require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0o644))
|
||||||
}
|
}
|
||||||
@@ -409,20 +330,20 @@ func TestScannerLargeFileEnumeration(t *testing.T) {
|
|||||||
err := s.EnumeratePath("/testdir", progress)
|
err := s.EnumeratePath("/testdir", progress)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// progress is fully buffered and closed; no draining needed
|
// Drain channel
|
||||||
|
for range progress {
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t, FileCount(100), s.FileCount())
|
assert.Equal(t, FileCount(100), s.FileCount())
|
||||||
assert.Equal(t, FileSize(400), s.TotalBytes()) // 100 * 4 bytes
|
assert.Equal(t, FileSize(400), s.TotalBytes()) // 100 * 4 bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsHiddenPath(t *testing.T) {
|
func TestIsHiddenPath(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
hidden bool
|
hidden bool
|
||||||
}{
|
}{
|
||||||
{testFileName, false},
|
{"file.txt", false},
|
||||||
{".hidden", true},
|
{".hidden", true},
|
||||||
{"dir/file.txt", false},
|
{"dir/file.txt", false},
|
||||||
{"dir/.hidden", true},
|
{"dir/.hidden", true},
|
||||||
@@ -439,8 +360,6 @@ func TestIsHiddenPath(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.path, func(t *testing.T) {
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.hidden, IsHiddenPath(tt.path), "IsHiddenPath(%q)", tt.path)
|
assert.Equal(t, tt.hidden, IsHiddenPath(tt.path), "IsHiddenPath(%q)", tt.path)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-84
@@ -2,14 +2,12 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"time"
|
"time"
|
||||||
"uuid"
|
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
@@ -17,80 +15,47 @@ import (
|
|||||||
// MAGIC is the file format magic bytes prefix (rot13 of "MANIFEST").
|
// MAGIC is the file format magic bytes prefix (rot13 of "MANIFEST").
|
||||||
const MAGIC string = "ZNAVSRFG"
|
const MAGIC string = "ZNAVSRFG"
|
||||||
|
|
||||||
var (
|
|
||||||
// errInnerNotSet is returned by generate when the inner manifest is
|
|
||||||
// missing.
|
|
||||||
errInnerNotSet = errors.New("internal error: pbInner not set")
|
|
||||||
// errInternal is returned by generateOuter for the same condition.
|
|
||||||
// The two messages differ, and both are load-bearing for callers that
|
|
||||||
// match on text, so they are kept distinct.
|
|
||||||
errInternal = errors.New("internal error")
|
|
||||||
)
|
|
||||||
|
|
||||||
// nanosecondsInt32 converts t's nanosecond component to int32.
|
|
||||||
// time.Time.Nanosecond is documented to return a value in [0, 999999999],
|
|
||||||
// so the conversion cannot overflow. This sits directly in the manifest
|
|
||||||
// content path: silently substituting a default would zero every entry's
|
|
||||||
// mtime nanos and change the serialized bytes and their hash, so an
|
|
||||||
// out-of-contract value is a programming error and panics rather than
|
|
||||||
// being papered over.
|
|
||||||
func nanosecondsInt32(t time.Time) int32 {
|
|
||||||
n := t.Nanosecond()
|
|
||||||
if n < 0 || n > math.MaxInt32 {
|
|
||||||
panic(fmt.Sprintf(
|
|
||||||
"mfer: time.Time.Nanosecond out of contract: %d", n))
|
|
||||||
}
|
|
||||||
|
|
||||||
return int32(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTimestampFromTime(t time.Time) *Timestamp {
|
func newTimestampFromTime(t time.Time) *Timestamp {
|
||||||
return &Timestamp{
|
return &Timestamp{
|
||||||
Seconds: t.Unix(),
|
Seconds: t.Unix(),
|
||||||
Nanos: nanosecondsInt32(t),
|
Nanos: int32(t.Nanosecond()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manifest) generate(ctx context.Context) error {
|
func (m *manifest) generate() error {
|
||||||
if m.pbInner == nil {
|
if m.pbInner == nil {
|
||||||
return errInnerNotSet
|
return errors.New("internal error: pbInner not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.pbOuter == nil {
|
if m.pbOuter == nil {
|
||||||
e := m.generateOuter(ctx)
|
e := m.generateOuter()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
|
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("serialize: marshal outer: %w", err)
|
return fmt.Errorf("serialize: marshal outer: %w", err)
|
||||||
}
|
}
|
||||||
|
m.output = bytes.NewBuffer([]byte(MAGIC))
|
||||||
m.output = bytes.NewBufferString(MAGIC)
|
|
||||||
|
|
||||||
_, err = m.output.Write(dat)
|
_, err = m.output.Write(dat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("serialize: write output: %w", err)
|
return fmt.Errorf("serialize: write output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manifest) generateOuter(ctx context.Context) error {
|
func (m *manifest) generateOuter() error {
|
||||||
if m.pbInner == nil {
|
if m.pbInner == nil {
|
||||||
return errInternal
|
return errors.New("internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use fixed UUID if provided, otherwise generate a new one
|
// Use fixed UUID if provided, otherwise generate a new one
|
||||||
var manifestUUID uuid.UUID
|
var manifestUUID uuid.UUID
|
||||||
if len(m.fixedUUID) == uuidLength {
|
if len(m.fixedUUID) == 16 {
|
||||||
copy(manifestUUID[:], m.fixedUUID)
|
copy(manifestUUID[:], m.fixedUUID)
|
||||||
} else {
|
} else {
|
||||||
manifestUUID = uuid.New()
|
manifestUUID = uuid.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pbInner.Uuid = manifestUUID[:]
|
m.pbInner.Uuid = manifestUUID[:]
|
||||||
|
|
||||||
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
|
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
|
||||||
@@ -100,29 +65,23 @@ func (m *manifest) generateOuter(ctx context.Context) error {
|
|||||||
|
|
||||||
// Compress the inner data
|
// Compress the inner data
|
||||||
idc := new(bytes.Buffer)
|
idc := new(bytes.Buffer)
|
||||||
|
|
||||||
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("serialize: create compressor: %w", err)
|
return fmt.Errorf("serialize: create compressor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = zw.Write(innerData)
|
_, err = zw.Write(innerData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("serialize: compress: %w", err)
|
return fmt.Errorf("serialize: compress: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = zw.Close()
|
_ = zw.Close()
|
||||||
|
|
||||||
compressedData := idc.Bytes()
|
compressedData := idc.Bytes()
|
||||||
|
|
||||||
// Hash the compressed data for integrity verification before decompression
|
// Hash the compressed data for integrity verification before decompression
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
if _, err := h.Write(compressedData); err != nil {
|
||||||
_, err = h.Write(compressedData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("serialize: hash write: %w", err)
|
return fmt.Errorf("serialize: hash write: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sha256Hash := h.Sum(nil)
|
sha256Hash := h.Sum(nil)
|
||||||
|
|
||||||
m.pbOuter = &MFFileOuter{
|
m.pbOuter = &MFFileOuter{
|
||||||
@@ -136,40 +95,29 @@ func (m *manifest) generateOuter(ctx context.Context) error {
|
|||||||
|
|
||||||
// Sign the manifest if signing options are provided
|
// Sign the manifest if signing options are provided
|
||||||
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
||||||
return m.signOuter(ctx)
|
sigString, err := m.signatureString()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate signature string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := gpgSign([]byte(sigString), m.signingOptions.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sign manifest: %w", err)
|
||||||
|
}
|
||||||
|
m.pbOuter.Signature = sig
|
||||||
|
|
||||||
|
fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
m.pbOuter.Signer = fingerprint
|
||||||
|
|
||||||
|
pubKey, err := gpgExportPublicKey(m.signingOptions.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to export public key: %w", err)
|
||||||
|
}
|
||||||
|
m.pbOuter.SigningPubKey = pubKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// signOuter signs the outer message with the configured GPG key and
|
|
||||||
// embeds the signature, signer fingerprint, and public key.
|
|
||||||
func (m *manifest) signOuter(ctx context.Context) error {
|
|
||||||
sigString, err := m.signatureString()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate signature string: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sig, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to sign manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pbOuter.Signature = sig
|
|
||||||
|
|
||||||
fingerprint, err := gpgGetKeyFingerprint(ctx, m.signingOptions.KeyID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pbOuter.Signer = fingerprint
|
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(ctx, m.signingOptions.KeyID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to export public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pbOuter.SigningPubKey = pubKey
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\xff\xff\xff\x03\xc2\x06 {\x16\xbdu\xa0\xa2\x11\xfcH\xef*\x1b7\r\x99\xefb\x04\x02g\n\xa9\xf3B5\xe5p\x96\x8c\x8c\xac\x0e\xca\x06\x10\x03Q\xb2\xd0\x19`F\xc1\xb1\xc0Z\xf4x\xf4g^\xba\f\xa1\x06(\xb5/\xfd\x04h\x04\x01\x00d\x01\xb2\x06\x10\x03Q\xb2\xd0\x19`F\xc1\xb1\xc0Z\xf4x\xf4g^\xaa\x06\x00\x01T\x13\x024\xce\xff\rL\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15M\x00\x00\x00\x01T\x00\x044\xfc\xff\x153\xea\a\xb4")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\xff\xff\xff\x03\xc2\x06 .\xcd\x11|0\xfcP\xe5\x1b\xe3\xc6Ӡ\xcdڤx\xcd\x169t\x1a9~ǽB\xc9\xe8G`\x05\xca\x06\x10\x11*!\x0e\x95EF\xb8\xbd\x9f\xde\x12MF\r\x99\xba\f\xa6\x06(\xb5/\xfd\x04h,\x01\x00\xb4\x01\xb2\x06\x10\x11*!\x0e\x95EF\xb8\xbd\x9f\xde\x12MF\r\x99\xaa\x06\xe6\xff\xff\x03\x1a\x00\x01T\x14\x024\x8b\xff\x17L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15L\x00\x00\x00\x01T\x00\x044\xfd\xff\x15M\x00\x00\x00\x01T\x00\x044\xfc\xff\x15\x02\xd1.\xe3")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFGy[i\x04\xe5O\x82A\x1d\xf4\xb0\xe2z7:U\xee\xa3\xf9\xd6m\xacZ\x9b\xce\x1d\xd9/{@\x1d\xa5y[i\x04\xe5O\x82A\x1d\xf4\xb0\xe2z7:U\xee\xa3\xf9\xd6m\xacZ\x9b\xce\x1d\xd9/{@\x1d\xa5")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG\x80")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG\x80\xca\f\xe8\x03-----BEGIN PGP SIGNATURE-----\n\niQEzBAABCgAdFiEET1Yr+4Y/3GtRtO6IhypRF2zvI64FAmrBIXAACgkQhypRF2zv\nI67BQAf/QrpX2MjY15YGMGkjR5oIhnx/YV96aGYZyZThzb+l/R/N75iVFVkhX21d\nZhQqdCsORrodTPAXic2g2UGVXP9PhNMh7n6Wm3LsvQYjrRQGrQnqtCkut+3tUt8K\n7pt4OAnnwRSieaVImA1COmzxIrQQKNOs6UkgmAstGuPV0XZoeDiSG8TUYJ/vieCn\np5hC0FFXtzfw4NtkxSmkewE0xBxIwFCA/RfSHCGH3m5K+tRz41vMEgGbL1iEp6+V\nuBaoEc4hqCgEt+Af2pA8VHfqeu2vKiwggOpYpaILXZKVqH9+tWHL1EBv9t0vTsYE\n9D57euuR9+kOdngYNPieP1yn5dOSHg==\n=kvgL\n-----END PGP SIGNATURE-----\n\xd2\f(4F562BFB863FDC6B51B4EE88872A51176CEF23AE\xda\f\xb5\a-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQENBGrBIW8BCADESetN5EdxIe7Fafgxl99Yoo5cOexf7wJyYT0wfUYlRaxt3neR\nhir7LOfH4PZWWoDx7qghxCS4+vs7yGypl6JOm7jnJlhn4HneDa2zeIlgGW2TamyE\nua9KPWBQqkFOYmKPmzp+KnL6ncnBLR5mDkNKFyON812KVvteu6Dp/DNk4Meufe44\nWWr49LSFZa9gEbmRCoQGKby9F0H0yIi4FAc74VdQudy0+fMKcfkKjEvByMzlbBEK\n92Hq3sRFzWd3kvPliNjZTmlh5n5m9aBhMpoy3GkKy8gpDdFc6NLA9iAJe7oNMriR\nkVoa5EjQL1xCXAiAWTYA9NScFfU/574sCTxZABEBAAG0Hk1GRVIgVGVzdCBLZXkg\nPHRlc3RAbWZlci50ZXN0PokBTwQTAQoAORYhBE9WK/uGP9xrUbTuiIcqURds7yOu\nBQJqwSFvAxsvBAULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCHKlEXbO8jrjml\nCAC8wUK9wmvxq0+NZUpFyP+P29klLZYzBDaBrLPJFs0GjnG4kvfUAktWx0Ro80F7\ncjTJ4f44XjDj4glvSjbe2VaDnZl9FTfzUfG+xjD4462NgntQ4fHk/uG4F6d1ikWx\nkEoMpIn1PlSMas1jTQSGlxUr+zFwWuUbGq4n6hRxEnwLlwJlwQt/Aw1vPDYuPDE3\nOYDhJIAJyP+6e9W8ToaAG9byg/22KA1u1qxnNQqsx5Tped2VltAzdYub+yeCuNc8\nIUo5ILo/fQq3GM5sUEaHjPolv88WlDm3vcdbSbDoh5m2inDtg5zuUKJwu32UGu8l\nKoTjp4nxgQGy5WmeBzs4Hdm/\n=TCB8\n-----END PGP PUBLIC KEY BLOCK-----\n")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06!\xc2\x06 \x91\x90*\xa5>\fݐ \x87\xbeaL\xc1\x05?\x0eR\xc18\xa4eՕ\xa95\xb9KʺoZ\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\f-(\xb5/\xfd\x04\x00\x01\x01\x00\xa0\x06\x01\xaa\x06\a\n\x05a.txt\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3a[k'")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\x1f\xc2\x06 \x91\x90*\xa5>\fݐ \x87\xbeaL\xc1\x05?\x0eR\xc18\xa4eՕ\xa95\xb9KʺoZ\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\f-(\xb5/\xfd\x04\x00\x01\x01\x00\xa0\x06\x01\xaa\x06\a\n\x05a.txt\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3a[k'")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 ")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAV")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFX\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG\x80")
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\x01\xc2\x06 \xd6aQ\xa4\x85\xc0+\xbb\xca\x11\x11\a<\x019\x97\xb3\xbb3\xd0 \xd5U\xfa!\xaeAf<N@\x9d\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\f\x12(\xb5/\xfd\xc0\x00\x00\x00\x00\x00\x02\x00\x00\x00\v\x00\x00\x00")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\x01\xc2\x06 \xc8g?\xa0\xc9\xc5]\xb57M\xe5O#\x04\xb2\x12\xc6)@=\xd2\xf3f/͉~\x10\x15\xfbkD\xca\x06\x10o\x1c*N;]L~\x9a\x8b\x1d.?@Qb\xba\f\x89\t(\xb5/\xfd\x00\x00\x01\x00\x00(\xb5/\xfd\x00\x01\x01\x00\x00(\xb5/\xfd\x00\x02\x01\x00\x00(\xb5/\xfd\x00\x03\x01\x00\x00(\xb5/\xfd\x00\x04\x01\x00\x00(\xb5/\xfd\x00\x05\x01\x00\x00(\xb5/\xfd\x00\x06\x01\x00\x00(\xb5/\xfd\x00\a\x01\x00\x00(\xb5/\xfd\x00\b\x01\x00\x00(\xb5/\xfd\x00\t\x01\x00\x00(\xb5/\xfd\x00\n\x01\x00\x00(\xb5/\xfd\x00\v\x01\x00\x00(\xb5/\xfd\x00\f\x01\x00\x00(\xb5/\xfd\x00\r\x01\x00\x00(\xb5/\xfd\x00\x0e\x01\x00\x00(\xb5/\xfd\x00\x0f\x01\x00\x00(\xb5/\xfd\x00\x10\x01\x00\x00(\xb5/\xfd\x00\x11\x01\x00\x00(\xb5/\xfd\x00\x12\x01\x00\x00(\xb5/\xfd\x00\x13\x01\x00\x00(\xb5/\xfd\x00\x14\x01\x00\x00(\xb5/\xfd\x00\x15\x01\x00\x00(\xb5/\xfd\x00\x16\x01\x00\x00(\xb5/\xfd\x00\x17\x01\x00\x00(\xb5/\xfd\x00\x18\x01\x00\x00(\xb5/\xfd\x00\x19\x01\x00\x00(\xb5/\xfd\x00\x1a\x01\x00\x00(\xb5/\xfd\x00\x1b\x01\x00\x00(\xb5/\xfd\x00\x1c\x01\x00\x00(\xb5/\xfd\x00\x1d\x01\x00\x00(\xb5/\xfd\x00\x1e\x01\x00\x00(\xb5/\xfd\x00\x1f\x01\x00\x00(\xb5/\xfd\x00 \x01\x00\x00(\xb5/\xfd\x00!\x01\x00\x00(\xb5/\xfd\x00\"\x01\x00\x00(\xb5/\xfd\x00#\x01\x00\x00(\xb5/\xfd\x00$\x01\x00\x00(\xb5/\xfd\x00%\x01\x00\x00(\xb5/\xfd\x00&\x01\x00\x00(\xb5/\xfd\x00'\x01\x00\x00(\xb5/\xfd\x00(\x01\x00\x00(\xb5/\xfd\x00)\x01\x00\x00(\xb5/\xfd\x00*\x01\x00\x00(\xb5/\xfd\x00+\x01\x00\x00(\xb5/\xfd\x00,\x01\x00\x00(\xb5/\xfd\x00-\x01\x00\x00(\xb5/\xfd\x00.\x01\x00\x00(\xb5/\xfd\x00/\x01\x00\x00(\xb5/\xfd\x000\x01\x00\x00(\xb5/\xfd\x001\x01\x00\x00(\xb5/\xfd\x002\x01\x00\x00(\xb5/\xfd\x003\x01\x00\x00(\xb5/\xfd\x004\x01\x00\x00(\xb5/\xfd\x005\x01\x00\x00(\xb5/\xfd\x006\x01\x00\x00(\xb5/\xfd\x007\x01\x00\x00(\xb5/\xfd\x008\x01\x00\x00(\xb5/\xfd\x009\x01\x00\x00(\xb5/\xfd\x00:\x01\x00\x00(\xb5/\xfd\x00;\x01\x00\x00(\xb5/\xfd\x00<\x01\x00\x00(\xb5/\xfd\x00=\x01\x00\x00(\xb5/\xfd\x00>\x01\x00\x00(\xb5/\xfd\x00?\x01\x00\x00(\xb5/\xfd\x00@\x01\x00\x00(\xb5/\xfd\x00A\x01\x00\x00(\xb5/\xfd\x00B\x01\x00\x00(\xb5/\xfd\x00C\x01\x00\x00(\xb5/\xfd\x00D\x01\x00\x00(\xb5/\xfd\x00E\x01\x00\x00(\xb5/\xfd\x00F\x01\x00\x00(\xb5/\xfd\x00G\x01\x00\x00(\xb5/\xfd\x00H\x01\x00\x00(\xb5/\xfd\x00I\x01\x00\x00(\xb5/\xfd\x00J\x01\x00\x00(\xb5/\xfd\x00K\x01\x00\x00(\xb5/\xfd\x00L\x01\x00\x00(\xb5/\xfd\x00M\x01\x00\x00(\xb5/\xfd\x00N\x01\x00\x00(\xb5/\xfd\x00O\x01\x00\x00(\xb5/\xfd\x00P\x01\x00\x00(\xb5/\xfd\x00Q\x01\x00\x00(\xb5/\xfd\x00R\x01\x00\x00(\xb5/\xfd\x00S\x01\x00\x00(\xb5/\xfd\x00T\x01\x00\x00(\xb5/\xfd\x00U\x01\x00\x00(\xb5/\xfd\x00V\x01\x00\x00(\xb5/\xfd\x00W\x01\x00\x00(\xb5/\xfd\x00X\x01\x00\x00(\xb5/\xfd\x00Y\x01\x00\x00(\xb5/\xfd\x00Z\x01\x00\x00(\xb5/\xfd\x00[\x01\x00\x00(\xb5/\xfd\x00\\\x01\x00\x00(\xb5/\xfd\x00]\x01\x00\x00(\xb5/\xfd\x00^\x01\x00\x00(\xb5/\xfd\x00_\x01\x00\x00(\xb5/\xfd\x00`\x01\x00\x00(\xb5/\xfd\x00a\x01\x00\x00(\xb5/\xfd\x00b\x01\x00\x00(\xb5/\xfd\x00c\x01\x00\x00(\xb5/\xfd\x00d\x01\x00\x00(\xb5/\xfd\x00e\x01\x00\x00(\xb5/\xfd\x00f\x01\x00\x00(\xb5/\xfd\x00g\x01\x00\x00(\xb5/\xfd\x00h\x01\x00\x00(\xb5/\xfd\x00i\x01\x00\x00(\xb5/\xfd\x00j\x01\x00\x00(\xb5/\xfd\x00k\x01\x00\x00(\xb5/\xfd\x00l\x01\x00\x00(\xb5/\xfd\x00m\x01\x00\x00(\xb5/\xfd\x00n\x01\x00\x00(\xb5/\xfd\x00o\x01\x00\x00(\xb5/\xfd\x00p\x01\x00\x00(\xb5/\xfd\x00q\x01\x00\x00(\xb5/\xfd\x00r\x01\x00\x00(\xb5/\xfd\x00s\x01\x00\x00(\xb5/\xfd\x00t\x01\x00\x00(\xb5/\xfd\x00u\x01\x00\x00(\xb5/\xfd\x00v\x01\x00\x00(\xb5/\xfd\x00w\x01\x00\x00(\xb5/\xfd\x00x\x01\x00\x00(\xb5/\xfd\x00y\x01\x00\x00(\xb5/\xfd\x00z\x01\x00\x00(\xb5/\xfd\x00{\x01\x00\x00(\xb5/\xfd\x00|\x01\x00\x00(\xb5/\xfd\x00}\x01\x00\x00(\xb5/\xfd\x00~\x01\x00\x00(\xb5/\xfd\x00\x7f\x01\x00\x00(\xb5/\xfd\x00\x80\x01\x00\x00")
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -32,14 +32,12 @@ func (b BaseURL) JoinPath(path RelFilePath) (FileURL, error) {
|
|||||||
for i, seg := range segments {
|
for i, seg := range segments {
|
||||||
segments[i] = url.PathEscape(seg)
|
segments[i] = url.PathEscape(seg)
|
||||||
}
|
}
|
||||||
|
|
||||||
ref, err := url.Parse(strings.Join(segments, "/"))
|
ref, err := url.Parse(strings.Join(segments, "/"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved := base.ResolveReference(ref)
|
resolved := base.ResolveReference(ref)
|
||||||
|
|
||||||
return FileURL(resolved.String()), nil
|
return FileURL(resolved.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-18
@@ -1,4 +1,3 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,27 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestBaseURLJoinPath(t *testing.T) {
|
func TestBaseURLJoinPath(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
base BaseURL
|
base BaseURL
|
||||||
path RelFilePath
|
path RelFilePath
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{"https://example.com/dir/", testFileName, "https://example.com/dir/file.txt"},
|
{"https://example.com/dir/", "file.txt", "https://example.com/dir/file.txt"},
|
||||||
{"https://example.com/dir", testFileName, "https://example.com/dir/file.txt"},
|
{"https://example.com/dir", "file.txt", "https://example.com/dir/file.txt"},
|
||||||
{"https://example.com/", "sub/file.txt", "https://example.com/sub/file.txt"},
|
{"https://example.com/", "sub/file.txt", "https://example.com/sub/file.txt"},
|
||||||
{
|
{"https://example.com/dir/", "file with spaces.txt", "https://example.com/dir/file%20with%20spaces.txt"},
|
||||||
"https://example.com/dir/",
|
|
||||||
"file with spaces.txt",
|
|
||||||
"https://example.com/dir/file%20with%20spaces.txt",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(string(tt.base)+"+"+string(tt.path), func(t *testing.T) {
|
t.Run(string(tt.base)+"+"+string(tt.path), func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
result, err := tt.base.JoinPath(tt.path)
|
result, err := tt.base.JoinPath(tt.path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tt.expected, string(result))
|
assert.Equal(t, tt.expected, string(result))
|
||||||
@@ -38,22 +29,16 @@ func TestBaseURLJoinPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseURLString(t *testing.T) {
|
func TestBaseURLString(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := BaseURL("https://example.com/")
|
b := BaseURL("https://example.com/")
|
||||||
assert.Equal(t, "https://example.com/", b.String())
|
assert.Equal(t, "https://example.com/", b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileURLString(t *testing.T) {
|
func TestFileURLString(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := FileURL("https://example.com/file.txt")
|
f := FileURL("https://example.com/file.txt")
|
||||||
assert.Equal(t, "https://example.com/file.txt", f.String())
|
assert.Equal(t, "https://example.com/file.txt", f.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifestURLString(t *testing.T) {
|
func TestManifestURLString(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := ManifestURL("https://example.com/index.mf")
|
m := ManifestURL("https://example.com/index.mf")
|
||||||
assert.Equal(t, "https://example.com/index.mf", m.String())
|
assert.Equal(t, "https://example.com/index.mf", m.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mfer",
|
|
||||||
"private": true,
|
|
||||||
"description": "Development tooling for the mfer repository: prettier, used by script/fmt and script/fmt-check to format and verify Markdown and JSON.",
|
|
||||||
"license": "WTFPL",
|
|
||||||
"devDependencies": {
|
|
||||||
"prettier": "3.9.6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/bootstrap: install all dependencies needed to build and develop
|
|
||||||
# this repo. Idempotent: every install is guarded by a check so already
|
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
|
||||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
|
||||||
# otherwise a pinned version is installed via nvm (installing nvm
|
|
||||||
# itself first, from a hash-verified release archive, never curl | sh).
|
|
||||||
#
|
|
||||||
# Uncomment the language sections in main() that apply to this repo.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# Pinned versions, 2026-07-06. Never "latest" or "lts"; exact versions.
|
|
||||||
# The node version is in .nvmrc, where script/prettier reads it too.
|
|
||||||
NODE_VERSION="$(cat "$ROOT/.nvmrc")"
|
|
||||||
NVM_VERSION="0.40.3"
|
|
||||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
|
||||||
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
|
||||||
YARN_VERSION="1.22.22"
|
|
||||||
# protoc v33.4, 2026-10-04, for script/generate. The sha256 of each
|
|
||||||
# platform's release archive is in ensure_protoc.
|
|
||||||
PROTOC_VERSION="33.4"
|
|
||||||
# gofumpt v0.12.0 for script/gofumpt, 2026-10-04, and protoc-gen-go
|
|
||||||
# v1.36.12 for script/generate, 2026-10-06: each is installed into bin/
|
|
||||||
# with `go install`, pinned to the commit its release tag names. Those two
|
|
||||||
# scripts refuse any other version, so a new pin is changed there too.
|
|
||||||
# protoc-gen-go stays at the google.golang.org/protobuf version in go.mod.
|
|
||||||
GOFUMPT_VERSION="v0.12.0"
|
|
||||||
GOFUMPT_COMMIT="3e07e7e70ac93761d8e79ca0083a19e3d59f753d"
|
|
||||||
PROTOC_GEN_GO_VERSION="v1.36.12"
|
|
||||||
PROTOC_GEN_GO_COMMIT="cdd4c5f7406e82462949c7a65defa9f3029c162d"
|
|
||||||
|
|
||||||
PKGMGR=""
|
|
||||||
SUDO=""
|
|
||||||
|
|
||||||
detect_pkgmgr() {
|
|
||||||
[ -n "$PKGMGR" ] && return 0
|
|
||||||
if command -v nix-env >/dev/null 2>&1; then
|
|
||||||
PKGMGR="nix"
|
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
|
||||||
PKGMGR="apt"
|
|
||||||
elif command -v brew >/dev/null 2>&1; then
|
|
||||||
PKGMGR="brew"
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
PKGMGR="apk"
|
|
||||||
else
|
|
||||||
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$PKGMGR" = "apt" ]; then
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
if [ "$(id -u)" != "0" ]; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
|
||||||
pkg_install() {
|
|
||||||
detect_pkgmgr
|
|
||||||
case "$PKGMGR" in
|
|
||||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
|
||||||
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
|
||||||
brew) brew install "$3" ;;
|
|
||||||
apk) apk add --no-cache "$4" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
missing() {
|
|
||||||
! command -v "$1" >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# verify_sha256 <file> <expected-hash>
|
|
||||||
verify_sha256() {
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
|
||||||
else
|
|
||||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
|
||||||
fi
|
|
||||||
if [ "$actual" != "$2" ]; then
|
|
||||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
|
||||||
echo " expected: $2" >&2
|
|
||||||
echo " actual: $actual" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# nvm is a bash script; run a command in a bash with nvm loaded.
|
|
||||||
# --no-use: otherwise loading nvm here switches to the version .nvmrc
|
|
||||||
# names, and fails silently while that version is not installed yet.
|
|
||||||
nvm_sh() {
|
|
||||||
bash -c ". \"\$HOME/.nvm/nvm.sh\" --no-use && $*"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_nvm() {
|
|
||||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
|
||||||
# nvm prerequisites; nvm itself requires bash, so install it too
|
|
||||||
if missing bash; then pkg_install bash bash bash bash; fi
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
if missing git; then pkg_install git git git git; fi
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
|
||||||
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
|
||||||
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
|
||||||
mkdir -p "$HOME/.nvm"
|
|
||||||
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_node() {
|
|
||||||
if ! missing node; then return 0; fi
|
|
||||||
ensure_nvm
|
|
||||||
nvm_sh "nvm install $NODE_VERSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_yarn() {
|
|
||||||
if ! missing yarn; then return 0; fi
|
|
||||||
if ! missing corepack; then
|
|
||||||
corepack enable
|
|
||||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
|
||||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
|
||||||
corepack prepare yarn@$YARN_VERSION --activate"
|
|
||||||
else
|
|
||||||
npm install -g "yarn@$YARN_VERSION"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_js_deps() {
|
|
||||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
|
||||||
yarn install --frozen-lockfile"
|
|
||||||
else
|
|
||||||
yarn install --frozen-lockfile
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Unpack protoc's release archive for this platform into bin/protoc, after
|
|
||||||
# checking the archive's sha256, unless bin/protoc already holds the pinned
|
|
||||||
# version. script/generate runs bin/protoc/bin/protoc, so that is the
|
|
||||||
# binary checked again after unpacking.
|
|
||||||
ensure_protoc() {
|
|
||||||
dir="$ROOT/bin/protoc"
|
|
||||||
if [ "$("$dir/bin/protoc" --version 2>/dev/null)" = \
|
|
||||||
"libprotoc $PROTOC_VERSION" ]; then
|
|
||||||
echo "protoc $PROTOC_VERSION"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
case "$(uname -s) $(uname -m)" in
|
|
||||||
"Linux x86_64")
|
|
||||||
platform="linux-x86_64"
|
|
||||||
sha256="c0040ea9aef08fdeb2c74ca609b18d5fdbfc44ea0042fcfbfb38860d35f7dd66"
|
|
||||||
;;
|
|
||||||
"Linux aarch64" | "Linux arm64")
|
|
||||||
platform="linux-aarch_64"
|
|
||||||
sha256="15aa988f4a6090636525ec236a8e4b3aab41eef402751bd5bb2df6afd9b7b5a5"
|
|
||||||
;;
|
|
||||||
"Darwin x86_64")
|
|
||||||
platform="osx-x86_64"
|
|
||||||
sha256="a49bec10d039e902d3b43e49938c42526f90011467609864fa6386ac4014da58"
|
|
||||||
;;
|
|
||||||
"Darwin arm64")
|
|
||||||
platform="osx-aarch_64"
|
|
||||||
sha256="726297dcfed58592fd35620a5a6246ae020c39e88f3fd4cb1827df7bcf3dfcf1"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "bootstrap: no protoc archive pinned for $(uname -s) $(uname -m)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
if missing unzip; then pkg_install unzip unzip unzip unzip; fi
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fsSL -o "$tmp/protoc.zip" \
|
|
||||||
"https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${platform}.zip"
|
|
||||||
verify_sha256 "$tmp/protoc.zip" "$sha256"
|
|
||||||
rm -rf "$dir"
|
|
||||||
unzip -q "$tmp/protoc.zip" -d "$dir"
|
|
||||||
rm -rf "$tmp"
|
|
||||||
actual="$("$dir/bin/protoc" --version 2>/dev/null || true)"
|
|
||||||
if [ "$actual" != "libprotoc $PROTOC_VERSION" ]; then
|
|
||||||
echo "bootstrap: $dir/bin/protoc reports '$actual'," \
|
|
||||||
"not libprotoc $PROTOC_VERSION" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "protoc $PROTOC_VERSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install gofumpt into bin/ unless bin/gofumpt already reports the pinned
|
|
||||||
# version. script/gofumpt runs bin/gofumpt, so that is the binary checked
|
|
||||||
# again after installing. Its --version prints the version, then the Go
|
|
||||||
# version it was built with. The old file is removed first because
|
|
||||||
# `go install` refuses to replace a file that is not a Go binary.
|
|
||||||
ensure_gofumpt() {
|
|
||||||
tool="$ROOT/bin/gofumpt"
|
|
||||||
if [ "$("$tool" --version 2>/dev/null | cut -d' ' -f1)" != \
|
|
||||||
"$GOFUMPT_VERSION" ]; then
|
|
||||||
rm -f "$tool"
|
|
||||||
GOBIN="$ROOT/bin" go install "mvdan.cc/gofumpt@$GOFUMPT_COMMIT"
|
|
||||||
fi
|
|
||||||
actual="$("$tool" --version 2>/dev/null | cut -d' ' -f1)"
|
|
||||||
if [ "$actual" != "$GOFUMPT_VERSION" ]; then
|
|
||||||
echo "bootstrap: $tool reports '$actual', not $GOFUMPT_VERSION" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "gofumpt $GOFUMPT_VERSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install protoc-gen-go into bin/ unless bin/protoc-gen-go already reports
|
|
||||||
# the pinned version, as ensure_gofumpt does. script/generate runs
|
|
||||||
# bin/protoc-gen-go, so that is the binary checked again after installing.
|
|
||||||
ensure_protoc_gen_go() {
|
|
||||||
tool="$ROOT/bin/protoc-gen-go"
|
|
||||||
if [ "$("$tool" --version 2>/dev/null)" != \
|
|
||||||
"protoc-gen-go $PROTOC_GEN_GO_VERSION" ]; then
|
|
||||||
rm -f "$tool"
|
|
||||||
GOBIN="$ROOT/bin" go install \
|
|
||||||
"google.golang.org/protobuf/cmd/protoc-gen-go@$PROTOC_GEN_GO_COMMIT"
|
|
||||||
fi
|
|
||||||
actual="$("$tool" --version 2>/dev/null || true)"
|
|
||||||
if [ "$actual" != "protoc-gen-go $PROTOC_GEN_GO_VERSION" ]; then
|
|
||||||
echo "bootstrap: $tool reports '$actual'," \
|
|
||||||
"not protoc-gen-go $PROTOC_GEN_GO_VERSION" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "protoc-gen-go $PROTOC_GEN_GO_VERSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
# Base tooling (every repo)
|
|
||||||
if missing git; then pkg_install git git git git; fi
|
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
|
||||||
|
|
||||||
# ---- JS / docs repos ----
|
|
||||||
# This is a Go repo, but node and yarn are required anyway: prettier
|
|
||||||
# formats the Markdown and JSON, and script/fmt-check verifies it.
|
|
||||||
# The version is pinned by package.json/yarn.lock: yarn checks every
|
|
||||||
# package it fetches against its yarn.lock integrity hash, and
|
|
||||||
# --frozen-lockfile fails instead of rewriting a yarn.lock that no
|
|
||||||
# longer matches package.json.
|
|
||||||
ensure_node
|
|
||||||
ensure_yarn
|
|
||||||
install_js_deps
|
|
||||||
|
|
||||||
# ---- Go repos ----
|
|
||||||
if missing go; then pkg_install go golang go go; fi
|
|
||||||
# No golangci-lint: script/lint runs it in Docker only.
|
|
||||||
go mod download
|
|
||||||
ensure_gofumpt
|
|
||||||
ensure_protoc
|
|
||||||
ensure_protoc_gen_go
|
|
||||||
|
|
||||||
# ---- Python repos ----
|
|
||||||
# if missing python3; then pkg_install python3 python3 python3 python3; fi
|
|
||||||
# python3 -m venv .venv
|
|
||||||
# ./.venv/bin/pip install -e '.[dev]'
|
|
||||||
|
|
||||||
echo "bootstrap complete"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/build: build the mfer binary into bin/mfer, stamped with the
|
|
||||||
# revision `mfer version` prints, derived as script/docker derives it.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
# Own line: a failing command substitution inside an argument does
|
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
|
||||||
# empty constant.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
go build -ldflags "-X main.Gitrev=$version" -o bin/mfer ./cmd/mfer
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
|
||||||
# extension to scripts-to-rule-them-all. test and lint are Docker
|
|
||||||
# phases; fmt-check is native, because a formatter writes the working
|
|
||||||
# tree. Must not modify any files.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
"$SCRIPT_DIR/test"
|
|
||||||
"$SCRIPT_DIR/lint"
|
|
||||||
"$SCRIPT_DIR/fmt-check"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
|
||||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
|
||||||
# the formatter on the host, which a pristine checkout cannot do.
|
|
||||||
# --no-cache for the same reason as script/docker: the gate phases the
|
|
||||||
# final stage depends on are RUN steps, and a cached one is a check that
|
|
||||||
# did not run.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
"$SCRIPT_DIR/bootstrap"
|
|
||||||
"$SCRIPT_DIR/check"
|
|
||||||
# Own line: a failing command substitution inside an argument does
|
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
|
||||||
# the version a build stage derives from the .git in the context.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
docker build --no-cache \
|
|
||||||
--build-arg VERSION="$version" \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
|
||||||
# --no-cache because the gate phases the final stage depends on are RUN
|
|
||||||
# steps, and a cached one is a check that did not run.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
# Own line: a failing command substitution inside an argument does
|
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
|
||||||
# the version a build stage derives from the .git in the context.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
docker build --no-cache \
|
|
||||||
--build-arg VERSION="$version" \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/fmt: format all files (writes).
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
# Go, then Markdown and JSON, through the same scripts script/fmt-check
|
|
||||||
# uses, so both see the same files and the same tool versions.
|
|
||||||
"$SCRIPT_DIR/gofumpt" --write
|
|
||||||
"$SCRIPT_DIR/prettier" --write
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/fmt-check: check formatting (read-only). Same scope as
|
|
||||||
# script/fmt, but fails instead of writing: Go via script/gofumpt,
|
|
||||||
# Markdown and JSON via script/prettier.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
"$SCRIPT_DIR/gofumpt" --check
|
|
||||||
"$SCRIPT_DIR/prettier" --check
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/fuzz: fuzz the manifest parser for one minute. Run by hand only:
|
|
||||||
# script/test already runs the committed seed corpus as ordinary tests,
|
|
||||||
# and CI never fuzzes. An input that fails is written to
|
|
||||||
# mfer/testdata/fuzz/FuzzNewManifestFromReader/; once the parser is fixed,
|
|
||||||
# commit it there as a regression seed.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
go test -run '^$' -fuzz '^FuzzNewManifestFromReader$' \
|
|
||||||
-fuzztime 1m -parallel 2 ./mfer
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/generate: regenerate mfer/mf.pb.go from mfer/mf.proto, and record
|
|
||||||
# the hash of that mf.proto in mfer/mf.proto.sha256. Nothing else
|
|
||||||
# regenerates mf.pb.go: it is committed, so building and checking need no
|
|
||||||
# protoc. A test fails while mf.proto no longer matches the recorded hash.
|
|
||||||
#
|
|
||||||
# Runs the protoc that script/bootstrap unpacks into bin/protoc, and the
|
|
||||||
# protoc-gen-go it installs into bin/. Another version of either writes a
|
|
||||||
# different mf.pb.go.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# The versions script/bootstrap installs. protoc 33.4 names itself v6.33.4
|
|
||||||
# in the mf.pb.go header.
|
|
||||||
PROTOC_VERSION="33.4"
|
|
||||||
PROTOC="$ROOT/bin/protoc/bin/protoc"
|
|
||||||
PROTOC_GEN_GO_VERSION="v1.36.12"
|
|
||||||
PROTOC_GEN_GO="$ROOT/bin/protoc-gen-go"
|
|
||||||
|
|
||||||
# sha256 <file>: print "<hash> <file>", with sha256sum, or with shasum
|
|
||||||
# where there is no sha256sum.
|
|
||||||
sha256() {
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
sha256sum "$1"
|
|
||||||
elif command -v shasum >/dev/null 2>&1; then
|
|
||||||
shasum -a 256 "$1"
|
|
||||||
else
|
|
||||||
echo "generate: needs sha256sum or shasum on PATH" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
# A bin/protoc or bin/protoc-gen-go left from before its pin moved
|
|
||||||
# fails here, until script/bootstrap replaces it.
|
|
||||||
actual="$("$PROTOC" --version 2>/dev/null || true)"
|
|
||||||
if [ "$actual" != "libprotoc $PROTOC_VERSION" ]; then
|
|
||||||
echo "generate: needs protoc $PROTOC_VERSION in bin/protoc," \
|
|
||||||
"found: ${actual:-none}; run script/bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
actual="$("$PROTOC_GEN_GO" --version 2>/dev/null || true)"
|
|
||||||
if [ "$actual" != "protoc-gen-go $PROTOC_GEN_GO_VERSION" ]; then
|
|
||||||
echo "generate: needs protoc-gen-go $PROTOC_GEN_GO_VERSION in" \
|
|
||||||
"bin/protoc-gen-go, found: ${actual:-none}; run script/bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$ROOT/mfer"
|
|
||||||
# Hashed before regenerating, so a missing hash tool stops the script
|
|
||||||
# before it changes anything. Regenerating leaves mf.proto as it is.
|
|
||||||
proto_hash="$(sha256 mf.proto)"
|
|
||||||
"$PROTOC" --plugin=protoc-gen-go="$PROTOC_GEN_GO" \
|
|
||||||
--go_out=paths=source_relative:. ./mf.proto
|
|
||||||
echo "$proto_hash" >mf.proto.sha256
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/gofumpt: run gofumpt over this repo's Go files.
|
|
||||||
#
|
|
||||||
# Takes exactly one mode argument, --write or --check, and runs the same
|
|
||||||
# gofumpt version over the same files in both modes. script/fmt and
|
|
||||||
# script/fmt-check both go through here, so what gets formatted and what
|
|
||||||
# gets verified cannot drift apart.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# The gofumpt script/bootstrap installs into bin/. Must match the pin
|
|
||||||
# there.
|
|
||||||
GOFUMPT_VERSION="v0.12.0"
|
|
||||||
GOFUMPT="$ROOT/bin/gofumpt"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "usage: script/gofumpt --write|--check" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
[ "$#" -eq 1 ] || usage
|
|
||||||
# A bin/gofumpt left from before the pin moved formats differently, so
|
|
||||||
# it fails here until script/bootstrap replaces it.
|
|
||||||
actual="$("$GOFUMPT" --version 2>/dev/null | cut -d' ' -f1)"
|
|
||||||
if [ "$actual" != "$GOFUMPT_VERSION" ]; then
|
|
||||||
echo "gofumpt: needs $GOFUMPT_VERSION in bin/gofumpt," \
|
|
||||||
"found: ${actual:-none}; run script/bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Every Go file in the repo, from $ROOT down. gofumpt holds generated
|
|
||||||
# files, such as mfer/mf.pb.go, to gofmt's rules only.
|
|
||||||
case "$1" in
|
|
||||||
--write) "$GOFUMPT" -l -w "$ROOT" ;;
|
|
||||||
--check)
|
|
||||||
# Own line: a failing command inside `[ -n "$(...)" ]` does
|
|
||||||
# not trip `set -e`, so a gofumpt that never ran would pass.
|
|
||||||
unformatted="$("$GOFUMPT" -l "$ROOT")"
|
|
||||||
if [ -n "$unformatted" ]; then
|
|
||||||
echo "gofumpt: files need formatting (run make fmt):" >&2
|
|
||||||
echo "$unformatted" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/install-precommit: install the git pre-commit hook that runs
|
|
||||||
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
hook=".git/hooks/pre-commit"
|
|
||||||
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
|
||||||
chmod +x .git/hooks/pre-commit
|
|
||||||
echo "pre-commit hook installed: runs script/precommit"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/lint: run the linter. Linting is a phase of the Dockerfile and
|
|
||||||
# this builds that phase alone; the linter is never installed or run on
|
|
||||||
# a developer host, where a shared result cache and a host-global lock
|
|
||||||
# make its answer untrustworthy.
|
|
||||||
#
|
|
||||||
# The phase is not the last stage in the file, so it is built only when
|
|
||||||
# --target names it. --no-cache because a cached lint layer is a lint
|
|
||||||
# that did not run. The tag makes each build replace the previous image
|
|
||||||
# instead of leaving a dangling one behind.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
docker build --no-cache \
|
|
||||||
--target lint \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")-lint" .
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/precommit: run by the git pre-commit hook; fails the commit if
|
|
||||||
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
|
|
||||||
# extras run first: go mod tidy and go fmt, failing the commit if they
|
|
||||||
# change go.mod or go.sum.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
go mod tidy
|
|
||||||
go fmt ./...
|
|
||||||
git diff --exit-code -- go.mod go.sum ||
|
|
||||||
{ echo "go mod tidy changed files; stage and retry" >&2; exit 1; }
|
|
||||||
"$SCRIPT_DIR/check"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/prettier: run prettier over this repo's canonical file set.
|
|
||||||
#
|
|
||||||
# Takes exactly one mode argument, --write or --check, and applies the
|
|
||||||
# same patterns in both modes. script/fmt and script/fmt-check both go
|
|
||||||
# through here, so the set of files that get formatted and the set that
|
|
||||||
# get verified cannot drift apart.
|
|
||||||
#
|
|
||||||
# Failures are never swallowed: a missing prettier is an error, not a
|
|
||||||
# silent skip. A formatter that quietly does nothing is worse than one
|
|
||||||
# that fails loudly.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "usage: script/prettier --write|--check" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only the prettier yarn installed from yarn.lock, never one on PATH: a
|
|
||||||
# different version formats differently.
|
|
||||||
PRETTIER="$ROOT/node_modules/.bin/prettier"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
[ "$#" -eq 1 ] || usage
|
|
||||||
case "$1" in
|
|
||||||
--write | --check) mode="$1" ;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
# Where there is no node on PATH, script/bootstrap installs the version
|
|
||||||
# .nvmrc names through nvm, which keeps it in this directory.
|
|
||||||
if ! command -v node >/dev/null 2>&1; then
|
|
||||||
PATH="$HOME/.nvm/versions/node/v$(cat .nvmrc)/bin:$PATH"
|
|
||||||
fi
|
|
||||||
if ! command -v node >/dev/null 2>&1; then
|
|
||||||
echo "prettier: node is missing; run script/bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# node_modules keeps the old prettier after package.json moves to a new
|
|
||||||
# one, until script/bootstrap runs again, so compare the two.
|
|
||||||
if ! installed="$("$PRETTIER" --version 2>/dev/null)"; then
|
|
||||||
echo "prettier: not installed; run script/bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
pinned="$(node -p 'require("./package.json").devDependencies.prettier')"
|
|
||||||
if [ "$installed" != "$pinned" ]; then
|
|
||||||
echo "prettier: package.json pins $pinned but $installed is" \
|
|
||||||
"installed; run script/bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Markdown and JSON, repo-wide rather than root-only, so files in
|
|
||||||
# subdirectories (docs/, once it exists) are covered too. Exclusions
|
|
||||||
# live in .prettierignore.
|
|
||||||
#
|
|
||||||
# --no-error-on-unmatched-pattern is deliberately NOT used: both
|
|
||||||
# patterns always match at least one tracked file (README.md,
|
|
||||||
# package.json), so an empty match means the glob broke, and prettier
|
|
||||||
# erroring out is exactly what we want rather than a vacuous pass.
|
|
||||||
"$PRETTIER" "$mode" "**/*.md" "**/*.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user