Compare commits
28
Commits
3660173120
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce024baaed | ||
|
|
ab72692439 | ||
|
|
9bb0ab3a03 | ||
|
|
d3394bd2a2 | ||
|
|
a3e37d1ab8 | ||
|
|
acff23d92b | ||
|
|
8fe0244291 | ||
|
|
400a2f8f63 | ||
|
|
ba3d24ca42 | ||
|
|
11041330a7 | ||
|
|
5c7ef94799 | ||
|
|
82b9434444 | ||
|
|
6a2307a82b | ||
|
|
4bf87d1ccf | ||
|
|
e412d20c20 | ||
|
|
0501568203 | ||
|
|
0a9963002c | ||
|
|
76116005c8 | ||
|
|
7088857692 | ||
|
|
588c1bae74 | ||
|
|
64eb5cbd40 | ||
|
|
45eac1f6f8 | ||
|
|
b91e92b070 | ||
|
|
a2732cf8da | ||
|
|
a789eb3083 | ||
|
|
d00982b329 | ||
|
|
51f69c960d | ||
|
|
1adad7d3bc |
+5
-3
@@ -59,6 +59,8 @@
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# This repo's own host-built archives (Makefile).
|
||||
*.tmp
|
||||
*.dockerimage
|
||||
# This repo's own host-built binary (make build).
|
||||
/bin/mfer
|
||||
|
||||
# The protoc script/bootstrap unpacks for script/generate.
|
||||
/bin/protoc
|
||||
|
||||
+3
-7
@@ -1,14 +1,10 @@
|
||||
/bin/
|
||||
/bin/mfer
|
||||
/bin/protoc/
|
||||
/tmp
|
||||
/node_modules/
|
||||
*.tmp
|
||||
*.dockerimage
|
||||
/vendor
|
||||
vendor.tzst
|
||||
modcache.tzst
|
||||
|
||||
# Generated manifest files
|
||||
.index.mf
|
||||
/index.mf
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
@@ -26,7 +26,9 @@ source for coding standards, formatting, linting, and workflow rules.
|
||||
- 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 `FORMAT.md`.
|
||||
- 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.
|
||||
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.
|
||||
|
||||
+14
-12
@@ -8,17 +8,17 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Touch .pb.go so make does not try to regenerate via protoc (file is committed)
|
||||
RUN touch mfer/mf.pb.go
|
||||
|
||||
# Go half of fmt-check only: this image has no node, so no prettier. The
|
||||
# markdown half runs in the mdfmt stage below.
|
||||
RUN make fmt-check-go
|
||||
RUN make lint
|
||||
# markdown half runs in the mdfmt stage below. The image has no gofumpt
|
||||
# either; script/gofumpt builds the version bin/tools/go.mod pins.
|
||||
RUN script/gofumpt --check
|
||||
# The linter directly, not `make lint`: script/lint builds this stage, and
|
||||
# there is no docker inside this build.
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Markdown/JSON format stage — prettier needs node, which the Go images
|
||||
# do not have. node:22.17.0-bookworm-slim (2026-08-09); ships node
|
||||
# 22.17.0 and yarn 1.22.22, the versions script/bootstrap pins.
|
||||
# 22.17.0 and yarn 1.22.22, the versions .nvmrc and script/bootstrap pin.
|
||||
FROM node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0 AS mdfmt
|
||||
|
||||
WORKDIR /src
|
||||
@@ -31,7 +31,7 @@ COPY . .
|
||||
RUN script/prettier --check
|
||||
|
||||
# Build stage — tests and compilation
|
||||
# golang:1.23 (2026-03-14)
|
||||
# golang:1.23.12, 2026-03-14
|
||||
FROM golang@sha256:60deed95d3888cc5e4d9ff8a10c54e5edc008c6ae3fba6187be6fb592e19e8c0 AS builder
|
||||
|
||||
# Force BuildKit to run the lint and mdfmt stages by creating stage dependencies
|
||||
@@ -44,9 +44,6 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Touch .pb.go so make does not try to regenerate via protoc (file is committed)
|
||||
RUN touch mfer/mf.pb.go
|
||||
|
||||
RUN make test
|
||||
|
||||
# A build context sent as a tar archive, as upaas sends it, keeps its files'
|
||||
@@ -67,8 +64,13 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cd cmd/mfer && \
|
||||
go build -tags urfave_cli_no_docs -ldflags "-X main.Gitrev=$version" -o /mfer .
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.Gitrev=$version" -o /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'
|
||||
|
||||
FROM scratch
|
||||
# scratch has no CA certificates; fetch needs them to verify HTTPS servers.
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /mfer /mfer
|
||||
ENTRYPOINT ["/mfer"]
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
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 2>/dev/null || echo unknown)
|
||||
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)"
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks build generate fuzz
|
||||
|
||||
.PHONY: bootstrap setup docker default run ci test check lint fmt fmt-check fmt-check-go fmt-check-md hooks fixme
|
||||
|
||||
default: fmt test
|
||||
# Makefile targets are thin shims; the implementations live in script/
|
||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||
# of README.md).
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -23,79 +10,32 @@ bootstrap:
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
run: ./bin/mfer
|
||||
./$<
|
||||
./$< gen
|
||||
|
||||
ci: test
|
||||
|
||||
test:
|
||||
@script/test
|
||||
|
||||
$(PROTOC_GEN_GO):
|
||||
test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
|
||||
|
||||
fixme:
|
||||
@grep -nir fixme . | grep -v Makefile
|
||||
|
||||
check:
|
||||
@script/check
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
|
||||
# Halves of fmt-check, for environments that have only one toolchain:
|
||||
# the Docker lint stage has Go but no node, the markdown stage the reverse.
|
||||
fmt-check-go:
|
||||
@script/fmt-check-go
|
||||
|
||||
fmt-check-md:
|
||||
@script/prettier --check
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
devprereqs:
|
||||
which golangci-lint || go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
|
||||
mfer/mf.pb.go: mfer/mf.proto
|
||||
cd mfer && go generate .
|
||||
|
||||
bin/mfer: $(SOURCEFILES) mfer/mf.pb.go
|
||||
protoc --version
|
||||
cd cmd/mfer && go build -tags urfave_cli_no_docs -o ../../bin/mfer $(GOFLAGS) .
|
||||
|
||||
clean:
|
||||
rm -rfv mfer/*.pb.go bin/mfer cmd/mfer/mfer *.dockerimage
|
||||
|
||||
fmt:
|
||||
@script/fmt
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
|
||||
fmt:
|
||||
@script/fmt
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
|
||||
check:
|
||||
@script/check
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
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 $@
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
godoc:
|
||||
open http://127.0.0.1:6060
|
||||
godoc -http=:6060
|
||||
build:
|
||||
@script/build
|
||||
|
||||
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 $@
|
||||
generate:
|
||||
@script/generate
|
||||
|
||||
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 $@
|
||||
fuzz:
|
||||
@script/fuzz
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# mfer
|
||||
|
||||
[mfer](https://git.eeqj.de/sneak/mfer) is a reference implementation library and
|
||||
thin wrapper command-line utility written in [Go](https://golang.org) and first
|
||||
published in 2022 under the [WTFPL](https://wtfpl.net) (public domain) license.
|
||||
It specifies and generates `.mf` manifest files over a directory tree of files
|
||||
to encapsulate metadata about them (such as cryptographic checksums or
|
||||
signatures over same) to aid in archiving, downloading, and streaming, or
|
||||
mirroring. The manifest files' data is serialized with Google's
|
||||
[mfer](https://git.eeqj.de/sneak/mfer) is a [WTFPL](https://wtfpl.net)-licensed
|
||||
(public domain) [Go](https://golang.org) library and command-line tool by
|
||||
[@sneak](https://sneak.berlin) that specifies and generates `.mf` manifest files
|
||||
over a directory tree to encapsulate metadata about the files — such as
|
||||
cryptographic checksums and signatures over same — to aid in archiving,
|
||||
downloading, streaming, and mirroring. It was first published in 2022. The
|
||||
manifest files' data is serialized with Google's
|
||||
[protobuf serialization format](https://developers.google.com/protocol-buffers).
|
||||
The structure of these files can be found
|
||||
[in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
||||
@@ -21,6 +21,38 @@ This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
|
||||
as a de-facto standard and be incorporated into other software. A compatible
|
||||
javascript library is planned.
|
||||
|
||||
# Getting Started
|
||||
|
||||
`mfer` builds from source with a Go 1.23+ toolchain. The generated protobuf code
|
||||
is committed, so no `protoc` toolchain is required:
|
||||
|
||||
```sh
|
||||
git clone https://git.eeqj.de/sneak/mfer.git
|
||||
cd mfer
|
||||
make build
|
||||
```
|
||||
|
||||
Generate a manifest for a directory tree, verify it later, and fetch a published
|
||||
tree by URL:
|
||||
|
||||
```sh
|
||||
# Write index.mf, a manifest of the files under the current directory.
|
||||
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
|
||||
|
||||
CI runs `script/cibuild`, which builds the Docker image with `--no-cache`, so
|
||||
@@ -35,26 +67,51 @@ 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 (Go, golangci-lint, Go module
|
||||
download, and node/yarn plus the prettier version pinned in
|
||||
`package.json`/`yarn.lock`), idempotently
|
||||
- `script/bootstrap` — install all dependencies, idempotently: Go and the
|
||||
modules of both `go.mod` and `bin/tools/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`; and `protoc` 33.4,
|
||||
unpacked into `bin/protoc` from its release archive once the archive matches
|
||||
the sha256 the script holds for this platform. 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 (`go test`), regenerating the protobuf code
|
||||
first if it is stale
|
||||
- `script/lint` — run `golangci-lint` and verify `gofmt` cleanliness
|
||||
- `script/fmt` — format all code and docs (writes): `gofumpt`,
|
||||
`golangci-lint run --fix`, and `script/prettier --write`
|
||||
- `script/test` — run the test suite (`go test`); 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`, refusing any version but 33.4, and the `protoc-gen-go` that
|
||||
`bin/tools/go.mod` pins, which `go tool` builds from source checked against
|
||||
the hashes in `bin/tools/go.sum`
|
||||
- `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` (the Go format check, then the linter), uncached so it runs
|
||||
every time, then removes the image
|
||||
- `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`, at the version `bin/tools/go.mod` pins
|
||||
(built on demand by `go tool` from source checked against the hashes in
|
||||
`bin/tools/go.sum`, so nothing installs it); `script/fmt`, `script/fmt-check`
|
||||
and the Docker lint stage all 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`; 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/fmt-check-go`
|
||||
plus `script/prettier --check`
|
||||
- `script/fmt-check-go` — the Go half of `script/fmt-check`, on its own, for the
|
||||
Docker lint stage, whose image has no node
|
||||
`--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: builds the image with the same command as
|
||||
@@ -72,17 +129,18 @@ yet. Primary development happens on a privately-run Gitea instance at
|
||||
[tracked there](https://git.eeqj.de/sneak/mfer/issues).
|
||||
|
||||
Changes must always be formatted with a standard `go fmt`, syntactically valid,
|
||||
and must pass the linting defined in the repository (presently only the
|
||||
`golangci-lint` defaults), which can be run with a `make lint`. The `main`
|
||||
branch is protected and all changes must be made via
|
||||
[pull requests](https://git.eeqj.de/sneak/mfer/pulls) and pass CI to be merged.
|
||||
Any changes submitted to this project must also be
|
||||
and must pass the linting defined in the repository's `.golangci.yml`, which
|
||||
`make lint` runs in Docker. The `main` branch is protected and all changes must
|
||||
be made via [pull 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.
|
||||
|
||||
See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards,
|
||||
tooling requirements, and workflow conventions.
|
||||
|
||||
# Problem Statement
|
||||
# Rationale
|
||||
|
||||
## The problem
|
||||
|
||||
Given a plain URL, there is no standard way to safely and programmatically
|
||||
download everything "under" that URL path. `wget -r` can traverse directory
|
||||
@@ -106,7 +164,7 @@ Real issues I face:
|
||||
- when I download a large file via HTTP, I have no way of knowing if the file
|
||||
content is what it's supposed to be
|
||||
|
||||
# Proposed Solution
|
||||
## The solution
|
||||
|
||||
A standard, a manifest file format, and a tool for generating same.
|
||||
|
||||
@@ -138,6 +196,28 @@ The manifest file would do several important things:
|
||||
- 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
|
||||
|
||||
- Replace SHASUMS/SHASUMS.asc files
|
||||
@@ -157,18 +237,23 @@ The manifest file would do several important things:
|
||||
- metadata size should not be used as an excuse to sacrifice utility (such
|
||||
as providing checksums over each chunk of a large file)
|
||||
|
||||
# Open Questions
|
||||
# Original Design Questions
|
||||
|
||||
These were the open questions when the project started; open design questions
|
||||
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
|
||||
for the whole assembled file?
|
||||
|
||||
- If so, should the chunksize be fixed or dynamic?
|
||||
for the whole assembled file? If so, should the chunk size be fixed or
|
||||
dynamic? Still open, on
|
||||
[issue 81](https://git.eeqj.de/sneak/mfer/issues/81#issuecomment-118698).
|
||||
|
||||
- Should the manifest signature format be GnuPG signatures, or those from
|
||||
OpenBSD's signify (of which there is a good
|
||||
[golang implementation](https://github.com/frankbraun/gosignify)?
|
||||
[golang implementation](https://github.com/frankbraun/gosignify))? Still open,
|
||||
as question 10 on [issue 82](https://git.eeqj.de/sneak/mfer/issues/82).
|
||||
|
||||
- Should the on-disk serialization format be proto3 or json?
|
||||
- Should the on-disk serialization format be proto3 or json? Settled: it is
|
||||
proto3, see `docs/FORMAT.md` and `mfer/mf.proto`.
|
||||
|
||||
# Tool Examples
|
||||
|
||||
@@ -177,10 +262,25 @@ The manifest file would do several important things:
|
||||
- `mfer check` / `mfer check .`
|
||||
- verifies checksums of all files in manifest, displaying error and exiting
|
||||
nonzero if any files are missing or corrupted
|
||||
- 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/`
|
||||
- fetches `/stuff/index.mf` and downloads all files listed in manifest,
|
||||
optionally resuming any that already exist locally, and assures
|
||||
cryptographic integrity of downloaded files.
|
||||
- fetches `/stuff/index.mf` and downloads all files listed in manifest into
|
||||
the current directory, or the one given with `--dest`, and assures
|
||||
cryptographic integrity of downloaded files. A file already there with the
|
||||
size and hash 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`, then moved into place. A manifest is refused
|
||||
before any file is downloaded if it lists a file where fetch writes
|
||||
another: 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.
|
||||
- `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
|
||||
|
||||
@@ -266,9 +366,9 @@ Open work, open design questions included, is tracked in this repo's issues:
|
||||
- Issues:
|
||||
[https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues)
|
||||
|
||||
# Authors
|
||||
# Author
|
||||
|
||||
- [@sneak <sneak@sneak.berlin>](mailto:sneak@sneak.berlin)
|
||||
- [@sneak](https://sneak.berlin)
|
||||
|
||||
# License
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
if [[ ! -z "$GITREV" ]]; then
|
||||
echo $GITREV
|
||||
else
|
||||
git describe --tags --always --dirty=-dirty
|
||||
fi
|
||||
@@ -0,0 +1,22 @@
|
||||
// The developer tools this repo runs with `go tool`: gofumpt for
|
||||
// script/gofumpt and protoc-gen-go for script/generate. Kept out of the mfer
|
||||
// module so they add nothing to what mfer's users download. `go tool` builds
|
||||
// exactly the source whose hashes go.sum here records.
|
||||
module sneak.berlin/go/mfer/bin/tools
|
||||
|
||||
go 1.26.0
|
||||
|
||||
tool (
|
||||
google.golang.org/protobuf/cmd/protoc-gen-go
|
||||
mvdan.cc/gofumpt
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
// protoc-gen-go v1.36.11, 2026-10-04
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
// gofumpt v0.12.0, 2026-10-04
|
||||
mvdan.cc/gofumpt v0.12.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
|
||||
mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/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,19 +0,0 @@
|
||||
#!/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"
|
||||
@@ -19,7 +19,7 @@ An `.mf` file consists of two parts, concatenated:
|
||||
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.
|
||||
See [`mfer/mf.proto`](../mfer/mf.proto) for exact field numbers and types.
|
||||
|
||||
## Outer Message (`MFFileOuter`)
|
||||
|
||||
@@ -47,7 +47,11 @@ allows verifying data integrity before decompression.
|
||||
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.
|
||||
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 160,
|
||||
112, 64 and 16 bytes each, add up to more than 8 times its size.
|
||||
|
||||
## Inner Message (`MFFile`)
|
||||
|
||||
@@ -139,6 +143,6 @@ The recommended MIME type for `.mf` files is `application/octet-stream`. The
|
||||
|
||||
## Reference
|
||||
|
||||
- Proto definition: [`mfer/mf.proto`](mfer/mf.proto)
|
||||
- Proto definition: [`mfer/mf.proto`](../mfer/mf.proto)
|
||||
- Reference implementation:
|
||||
[git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
||||
@@ -3,42 +3,27 @@ module sneak.berlin/go/mfer
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/apex/log v1.9.0
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/dustin/go-humanize v1.0.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/pterm/pterm v0.12.35
|
||||
github.com/spf13/afero v1.8.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/urfave/cli/v2 v2.23.6
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/urfave/cli/v3 v3.14.0
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||
google.golang.org/protobuf v1.28.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atomicgo/cursor v0.0.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/fatih/color v1.7.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/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||
golang.org/x/sys v0.1.0 // 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
|
||||
)
|
||||
|
||||
@@ -38,21 +38,6 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
||||
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=
|
||||
@@ -61,8 +46,6 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -74,13 +57,9 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
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=
|
||||
@@ -134,21 +113,15 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
|
||||
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=
|
||||
@@ -158,22 +131,9 @@ github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
||||
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=
|
||||
@@ -182,61 +142,23 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
|
||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
|
||||
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/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60=
|
||||
github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
|
||||
github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
|
||||
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
|
||||
github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc=
|
||||
github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
|
||||
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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s=
|
||||
github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4=
|
||||
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=
|
||||
@@ -247,8 +169,9 @@ 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=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
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=
|
||||
@@ -292,7 +215,6 @@ 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=
|
||||
@@ -342,9 +264,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
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=
|
||||
@@ -375,15 +295,11 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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=
|
||||
@@ -542,18 +458,9 @@ google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175
|
||||
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=
|
||||
|
||||
+72
-45
@@ -2,6 +2,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,11 +11,12 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
@@ -74,31 +76,30 @@ func safeRateUint64(rate float64) uint64 {
|
||||
return uint64(rate)
|
||||
}
|
||||
|
||||
// findManifest looks for a manifest file in the given directory.
|
||||
// It checks for index.mf and .index.mf, returning the first one found.
|
||||
// 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) {
|
||||
candidates := []string{"index.mf", ".index.mf"}
|
||||
for _, name := range candidates {
|
||||
path := filepath.Join(dir, name)
|
||||
path := filepath.Join(dir, defaultManifestName)
|
||||
|
||||
exists, err := afero.Exists(fs, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return path, nil
|
||||
}
|
||||
exists, err := afero.Exists(fs, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"%w in %s (looked for index.mf and .index.mf)", errNoManifestFound, dir)
|
||||
if !exists {
|
||||
return "", fmt.Errorf("%w in %s (looked for %s)",
|
||||
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(url string) (string, error) {
|
||||
rc, fetchErr := mfa.openManifestReader(url)
|
||||
func (mfa *CLIApp) fetchManifestToTemp(
|
||||
ctx context.Context, url string,
|
||||
) (string, error) {
|
||||
rc, fetchErr := mfa.openManifestReader(ctx, url)
|
||||
if fetchErr != nil {
|
||||
return "", fetchErr
|
||||
}
|
||||
@@ -126,7 +127,9 @@ func (mfa *CLIApp) fetchManifestToTemp(url string) (string, error) {
|
||||
|
||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||
// against the manifest's embedded signing key.
|
||||
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
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))
|
||||
@@ -145,7 +148,7 @@ func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
// 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()
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to extract fingerprint from embedded signing key: %w", err)
|
||||
@@ -163,7 +166,9 @@ func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
}
|
||||
|
||||
// reportCheckProgress renders progress updates until the channel closes.
|
||||
func reportCheckProgress(progress <-chan mfer.CheckStatus) {
|
||||
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",
|
||||
@@ -203,22 +208,29 @@ func countCheckFailures(
|
||||
}
|
||||
|
||||
// findExtraFiles reports files present on disk but absent from the
|
||||
// manifest, counting each as a failure.
|
||||
func findExtraFiles(ctx *cli.Context, chk *mfer.Checker, failures *int64) error {
|
||||
// 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 {
|
||||
*failures++
|
||||
if cmd.Bool("no-extra-files") {
|
||||
*failures++
|
||||
|
||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||
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.Context, extraResults)
|
||||
err := chk.FindExtraFiles(ctx, extraResults)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for extra files: %w", err)
|
||||
}
|
||||
@@ -230,16 +242,24 @@ func findExtraFiles(ctx *cli.Context, chk *mfer.Checker, failures *int64) error
|
||||
|
||||
// runCheck runs the manifest check with progress and result reporting
|
||||
// and returns the number of failures.
|
||||
func runCheck(ctx *cli.Context, chk *mfer.Checker, showProgress bool) (int64, error) {
|
||||
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
|
||||
var (
|
||||
progress chan mfer.CheckStatus
|
||||
progressWg sync.WaitGroup
|
||||
)
|
||||
|
||||
if showProgress {
|
||||
progress = make(chan mfer.CheckStatus, 1)
|
||||
|
||||
go reportCheckProgress(progress)
|
||||
progressWg.Add(1)
|
||||
|
||||
go reportCheckProgress(progress, &progressWg)
|
||||
}
|
||||
|
||||
// Process results in a goroutine
|
||||
@@ -250,7 +270,10 @@ func runCheck(ctx *cli.Context, chk *mfer.Checker, showProgress bool) (int64, er
|
||||
go countCheckFailures(results, &failures, done)
|
||||
|
||||
// Run check
|
||||
err := chk.Check(ctx.Context, results, progress)
|
||||
err := chk.Check(ctx, results, progress)
|
||||
|
||||
progressWg.Wait()
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("check failed: %w", err)
|
||||
}
|
||||
@@ -258,28 +281,27 @@ func runCheck(ctx *cli.Context, chk *mfer.Checker, showProgress bool) (int64, er
|
||||
// Wait for results processing to complete
|
||||
<-done
|
||||
|
||||
// Check for extra files if requested
|
||||
if ctx.Bool("no-extra-files") {
|
||||
err = findExtraFiles(ctx, chk, &failures)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err = findExtraFiles(ctx, cmd, chk, &failures)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return failures, nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||
func (mfa *CLIApp) checkManifestOperation(
|
||||
ctx context.Context, cmd *cli.Command,
|
||||
) error {
|
||||
log.Debug("checkManifestOperation()")
|
||||
|
||||
manifestPath, err := mfa.resolveManifestArg(ctx)
|
||||
manifestPath, err := mfa.resolveManifestArg(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check: %w", err)
|
||||
}
|
||||
|
||||
// URL manifests need to be downloaded to a temp file for the checker
|
||||
if isHTTPURL(manifestPath) {
|
||||
tmpPath, tmpErr := mfa.fetchManifestToTemp(manifestPath)
|
||||
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
|
||||
if tmpErr != nil {
|
||||
return fmt.Errorf("check: %w", tmpErr)
|
||||
}
|
||||
@@ -289,21 +311,26 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||
manifestPath = tmpPath
|
||||
}
|
||||
|
||||
basePath := ctx.String("base")
|
||||
showProgress := ctx.Bool("progress")
|
||||
basePath := cmd.String("base")
|
||||
showProgress := cmd.Bool("progress")
|
||||
|
||||
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
||||
|
||||
// Create checker
|
||||
chk, err := mfer.NewChecker(manifestPath, basePath, mfa.Fs)
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: manifestPath,
|
||||
BasePath: basePath,
|
||||
Fs: mfa.Fs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check signature requirement
|
||||
requiredSigner := ctx.String("require-signature")
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if requiredSigner != "" {
|
||||
err = verifyRequiredSigner(chk, requiredSigner)
|
||||
err = verifyRequiredSigner(ctx, chk, requiredSigner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -312,7 +339,7 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||
log.Infof("manifest contains %d files, %s", chk.FileCount(),
|
||||
humanize.IBytes(safeUint64(int64(chk.TotalBytes()))))
|
||||
|
||||
failures, err := runCheck(ctx, chk, showProgress)
|
||||
failures, err := runCheck(ctx, cmd, chk, showProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+540
-27
@@ -5,15 +5,21 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
urfcli "github.com/urfave/cli/v2"
|
||||
urfcli "github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
@@ -23,10 +29,11 @@ const (
|
||||
testFile1 = "/testdir/file1.txt"
|
||||
testMF = "/testdir/test.mf"
|
||||
testOutput = "/output.mf"
|
||||
testOutputTmp = "/output.mf.tmp"
|
||||
testManifest = "/manifest.mf"
|
||||
testFlagBase = "--base"
|
||||
testFlagNoExtra = "--no-extra-files"
|
||||
testFlagVersion = "--version"
|
||||
testFlagVerbose = "--verbose"
|
||||
)
|
||||
|
||||
var errSimulatedWrite = errors.New("simulated write failure")
|
||||
@@ -39,12 +46,32 @@ var errSimulatedWrite = errors.New("simulated write failure")
|
||||
var runMu sync.Mutex
|
||||
|
||||
// runCLI invokes RunWithOptions while holding runMu so parallel tests
|
||||
// capture their own output.
|
||||
// capture their own output, and returns its exit code.
|
||||
func runCLI(opts *RunOptions) int {
|
||||
exitCode, _ := runCLIWithLevel(opts)
|
||||
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// runCLIWithLevel is runCLI that also returns the log level the run left
|
||||
// set, read while runMu still keeps other runs from changing it. Each run
|
||||
// starts at the default level, as a new process does. Before releasing the
|
||||
// lock it points the process-global logger at io.Discard: other tests log
|
||||
// outside the lock (manifest loads, scans), and those lines must not land in
|
||||
// this run's buffers once it has returned and its test is reading them.
|
||||
func runCLIWithLevel(opts *RunOptions) (int, log.Level) {
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
return RunWithOptions(opts)
|
||||
log.SetLevel(log.InfoLevel)
|
||||
|
||||
exitCode := RunWithOptions(opts)
|
||||
level := log.GetLevel()
|
||||
|
||||
log.SetOutput(io.Discard, io.Discard)
|
||||
log.Init()
|
||||
|
||||
return exitCode, level
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -102,7 +129,7 @@ func TestVersionCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{testApp, "version"}, fs)
|
||||
opts := testOpts([]string{testApp, cmdVersion}, fs)
|
||||
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
@@ -113,6 +140,181 @@ func TestVersionCommand(t *testing.T) {
|
||||
assert.Contains(t, stdout, "abc123")
|
||||
}
|
||||
|
||||
// TestVFlagCollision covers the -v/--verbose vs --version flag interaction
|
||||
// (issue #64). Verbose owns -v; version answers to --version and -V. None of
|
||||
// these invocations may produce a parser error, and the two ways of asking
|
||||
// for the version must print the same thing.
|
||||
func TestVFlagCollision(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Invocations that must print the version and exit 0.
|
||||
versionCases := map[string][]string{
|
||||
"long version flag": {testApp, testFlagVersion},
|
||||
"short version flag": {testApp, "-V"},
|
||||
"verbose then version": {testApp, "-v", testFlagVersion},
|
||||
"long verbose and version": {testApp, "--verbose", testFlagVersion},
|
||||
}
|
||||
|
||||
for name, args := range versionCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts(args, afero.NewMemMapFs())
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
||||
assert.Contains(t, testStdout(t, opts), mfer.Version)
|
||||
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
|
||||
})
|
||||
}
|
||||
|
||||
// Invocations that must enable verbose and exit 0 without a parser error.
|
||||
verboseCases := map[string][]string{
|
||||
"short verbose flag": {testApp, "-v"},
|
||||
"long verbose flag": {testApp, "--verbose"},
|
||||
}
|
||||
|
||||
for name, args := range verboseCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts(args, afero.NewMemMapFs())
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
||||
assert.Contains(t, testStdout(t, opts), cmdGenerate,
|
||||
"root should show help listing subcommands")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionFlagAndCommandMatch asserts that "mfer --version" and
|
||||
// "mfer version" produce identical output (issue #64).
|
||||
func TestVersionFlagAndCommandMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
flagOpts := testOpts([]string{testApp, testFlagVersion}, afero.NewMemMapFs())
|
||||
require.Equal(t, 0, runCLI(flagOpts))
|
||||
|
||||
cmdOpts := testOpts([]string{testApp, cmdVersion}, afero.NewMemMapFs())
|
||||
require.Equal(t, 0, runCLI(cmdOpts))
|
||||
|
||||
assert.Equal(t, testStdout(t, flagOpts), testStdout(t, cmdOpts))
|
||||
}
|
||||
|
||||
// TestRootVerbosityFlags asserts that -q and -v given before any subcommand
|
||||
// name take effect: in the root itself, and in the fetch and export
|
||||
// subcommands (issue #64). It does not run in parallel: other tests log
|
||||
// outside runMu (manifest loads, scans), and a line logged while one of these
|
||||
// runs is in progress lands in its output.
|
||||
//
|
||||
//nolint:paralleltest // see above
|
||||
func TestRootVerbosityFlags(t *testing.T) {
|
||||
t.Run("quiet with no subcommand hides the banner", func(t *testing.T) {
|
||||
loud := testOpts([]string{testApp}, afero.NewMemMapFs())
|
||||
require.Equal(t, 0, runCLI(loud))
|
||||
assert.Contains(t, testStdout(t, loud), banner)
|
||||
|
||||
quiet := testOpts([]string{testApp, "-q"}, afero.NewMemMapFs())
|
||||
require.Equal(t, 0, runCLI(quiet))
|
||||
assert.Contains(t, testStdout(t, quiet), cmdGenerate)
|
||||
assert.NotContains(t, testStdout(t, quiet), banner)
|
||||
})
|
||||
|
||||
t.Run("quiet before fetch hides the banner", func(t *testing.T) {
|
||||
opts := testOpts([]string{testApp, "-q", cmdFetch}, afero.NewMemMapFs())
|
||||
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts), errURLRequired.Error())
|
||||
assert.NotContains(t, testStdout(t, opts), banner)
|
||||
})
|
||||
|
||||
t.Run("verbose twice before fetch enables debug logging", func(t *testing.T) {
|
||||
opts := testOpts([]string{testApp, "-v", "-v", cmdFetch}, afero.NewMemMapFs())
|
||||
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts), "fetchManifestOperation()")
|
||||
})
|
||||
|
||||
t.Run("quiet before export hides the manifest summary", func(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
manifest := buildTestManifest(t, map[string][]byte{"a.txt": []byte("a")})
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
|
||||
|
||||
loud := testOpts([]string{testApp, cmdExport, testManifest}, fs)
|
||||
require.Equal(t, 0, runCLI(loud))
|
||||
assert.Contains(t, testStderr(t, loud), "loaded manifest")
|
||||
|
||||
quiet := testOpts([]string{testApp, "-q", cmdExport, testManifest}, fs)
|
||||
require.Equal(t, 0, runCLI(quiet))
|
||||
assert.NotContains(t, testStderr(t, quiet), "loaded manifest")
|
||||
})
|
||||
}
|
||||
|
||||
// commandsTakingVerbose returns the command lines -v can follow: the root and
|
||||
// the generate, check, freshen and fetch subcommands.
|
||||
func commandsTakingVerbose() [][]string {
|
||||
return [][]string{
|
||||
{testApp},
|
||||
{testApp, cmdGenerate},
|
||||
{testApp, cmdCheck},
|
||||
{testApp, cmdFreshen},
|
||||
{testApp, cmdFetch},
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerboseCount asserts that one -v or --verbose gives verbose output and
|
||||
// two -v, or -v and --verbose, give debug output (issue #125). urfave/cli
|
||||
// before v2.25.5 counted a flag given by its alias twice, so one -v gave debug
|
||||
// output.
|
||||
func TestVerboseCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
flags []string
|
||||
want log.Level
|
||||
}{
|
||||
{[]string{"-v"}, log.VerboseLevel},
|
||||
{[]string{testFlagVerbose}, log.VerboseLevel},
|
||||
{[]string{"-v", "-v"}, log.DebugLevel},
|
||||
{[]string{"-v", testFlagVerbose}, log.DebugLevel},
|
||||
}
|
||||
|
||||
for _, command := range commandsTakingVerbose() {
|
||||
for _, tc := range cases {
|
||||
args := slices.Concat(command, tc.flags)
|
||||
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, level := runCLIWithLevel(testOpts(args, afero.NewMemMapFs()))
|
||||
assert.Equal(t, tc.want, level)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinedShortVerboseRefused asserts that -vv is refused (issue #125):
|
||||
// single-letter flags do not combine, so the -v help text says -v -v.
|
||||
func TestCombinedShortVerboseRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range commandsTakingVerbose() {
|
||||
args := slices.Concat(command, []string{"-vv"})
|
||||
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts(args, afero.NewMemMapFs())
|
||||
exitCode, level := runCLIWithLevel(opts)
|
||||
|
||||
assert.Equal(t, 1, exitCode)
|
||||
assert.Contains(t, testStderr(t, opts), "flag provided but not defined: -vv")
|
||||
assert.Equal(t, log.InfoLevel, level)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -172,6 +374,74 @@ func TestGenerateAndCheckCommand(t *testing.T) {
|
||||
assert.Equal(t, 0, exitCode, "check failed: %s", testStderr(t, opts))
|
||||
}
|
||||
|
||||
// sharedWriter appends to a buffer shared with other sharedWriters, so
|
||||
// output written to stdout and stderr is kept in the order it was written.
|
||||
// Each write first waits for delay.
|
||||
type sharedWriter struct {
|
||||
mu *sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (w sharedWriter) Write(p []byte) (int, error) {
|
||||
time.Sleep(w.delay)
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
// TestCheckClearsProgressBeforeSummary asserts that check --progress writes
|
||||
// its last progress line and clears it before it logs the summary. Progress
|
||||
// writes are slowed down, so a progress goroutine that check did not wait for
|
||||
// would write after the summary, or after the run has returned.
|
||||
//
|
||||
// Progress lines and log lines take the logger's lock, and when the progress
|
||||
// goroutine gets it first the clear lands before the summary even without the
|
||||
// wait. Which goroutine gets it first changes from run to run, so check runs
|
||||
// ten times.
|
||||
func TestCheckClearsProgressBeforeSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello world")
|
||||
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "generate failed: %s", testStderr(t, opts))
|
||||
|
||||
for range 10 {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
output bytes.Buffer
|
||||
)
|
||||
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "--progress", testFlagBase, testDir, testMF,
|
||||
}, fs)
|
||||
opts.Stdout = sharedWriter{mu: &mu, buf: &output, delay: 100 * time.Millisecond}
|
||||
opts.Stderr = sharedWriter{mu: &mu, buf: &output}
|
||||
require.Equal(t, 0, runCLI(opts))
|
||||
|
||||
mu.Lock()
|
||||
got := output.String()
|
||||
mu.Unlock()
|
||||
|
||||
lastProgress := strings.Index(got, "Checking: 1/1 files")
|
||||
progressDone := strings.Index(got, "\r\033[K")
|
||||
summary := strings.Index(got, "checked 1 files")
|
||||
|
||||
require.NotEqual(t, -1, lastProgress, "no last progress line in %q", got)
|
||||
require.NotEqual(t, -1, progressDone, "progress line never cleared in %q", got)
|
||||
require.NotEqual(t, -1, summary, "no summary in %q", got)
|
||||
require.Less(t, lastProgress, progressDone,
|
||||
"progress cleared before its last line in %q", got)
|
||||
require.Less(t, progressDone, summary,
|
||||
"summary logged before progress was cleared in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCommandWithMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -283,7 +553,10 @@ func TestGenerateExcludesDotfilesByDefault(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify manifest only has 1 file (the non-dotfile)
|
||||
manifest, err := mfer.NewManifestFromFile(fs, testMF)
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: testMF,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 1)
|
||||
assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath())
|
||||
@@ -307,7 +580,10 @@ func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Verify manifest has 2 files (including dotfile)
|
||||
manifest, err := mfer.NewManifestFromFile(fs, testMF)
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: testMF,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
}
|
||||
@@ -409,30 +685,155 @@ func TestNoExtraFilesWithSubdirectory(t *testing.T) {
|
||||
"check should fail when extra files exist in subdirectory")
|
||||
}
|
||||
|
||||
func TestCheckWithoutNoExtraFilesIgnoresExtra(t *testing.T) {
|
||||
// TestCheckWarnsAboutUnlistedFiles adds one file the manifest does not list
|
||||
// to a tree that had none: it gets one warning and the check passes, unless
|
||||
// --no-extra-files makes it a failure. --quiet hides the warning, not the
|
||||
// failure.
|
||||
func TestCheckWarnsAboutUnlistedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
for name, unlisted := range map[string]string{
|
||||
"regular file": "extra.txt",
|
||||
"dotfile": ".hidden",
|
||||
"file in hidden directory": ".git/config",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testManifest, testDir,
|
||||
}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
// Add extra file
|
||||
writeTestFile(t, fs, "/testdir/extra.txt", "extra")
|
||||
check := func(flags ...string) (int, string) {
|
||||
args := append([]string{testApp, cmdCheck, testFlagBase, testDir}, flags...)
|
||||
opts := testOpts(append(args, testManifest), fs)
|
||||
|
||||
return runCLI(opts), testStderr(t, opts)
|
||||
}
|
||||
|
||||
exitCode, stderr := check()
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
|
||||
assert.NotContains(t, stderr, "not in manifest")
|
||||
|
||||
writeTestFile(t, fs, filepath.Join(testDir, unlisted), "unlisted")
|
||||
|
||||
exitCode, stderr = check()
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
|
||||
assert.Equal(t, 1, strings.Count(stderr, "not in manifest"), stderr)
|
||||
assert.Contains(t, stderr, unlisted)
|
||||
|
||||
exitCode, stderr = check("-q")
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
|
||||
assert.NotContains(t, stderr, "not in manifest")
|
||||
|
||||
exitCode, stderr = check(testFlagNoExtra)
|
||||
assert.Equal(t, 1, exitCode, "stderr: %s", stderr)
|
||||
assert.Contains(t, stderr, unlisted)
|
||||
|
||||
exitCode, _ = check("-q", testFlagNoExtra)
|
||||
assert.Equal(t, 1, exitCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNeverReportsManifest keeps the manifest inside the checked tree,
|
||||
// under several names and path spellings, and names the tree both directly
|
||||
// and through a symlink: the manifest is never reported, even under
|
||||
// --no-extra-files. The manifest is recognized by file identity, which needs
|
||||
// the real filesystem.
|
||||
func TestCheckNeverReportsManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Manifest paths are relative to the checked tree.
|
||||
for name, manifest := range map[string]string{
|
||||
"default name": defaultManifestName,
|
||||
"hidden name": ".index.mf",
|
||||
"other name in a subdirectory": "sub/listing.mf",
|
||||
"path spelled through ..": "sub/../index.mf",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A temp dir holding data/tree and link, a symlink to data.
|
||||
root := t.TempDir()
|
||||
tree := filepath.Join(root, "data", "tree")
|
||||
// Not filepath.Join, which would clean away a "..".
|
||||
manifestPath := tree + "/" + manifest
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(tree, "sub"), 0o750))
|
||||
require.NoError(t,
|
||||
os.Symlink(filepath.Join(root, "data"), filepath.Join(root, "link")))
|
||||
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", manifestPath, tree,
|
||||
}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
for _, base := range []string{tree, filepath.Join(root, "link", "tree")} {
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, testFlagNoExtra, testFlagBase, base, manifestPath,
|
||||
}, fs)
|
||||
assert.Equal(t, 0, runCLI(opts),
|
||||
"base %s, stderr: %s", base, testStderr(t, opts))
|
||||
assert.NotContains(t, testStderr(t, opts), "not in manifest")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// unlistableDirFs is a filesystem on which one directory cannot be listed.
|
||||
type unlistableDirFs struct {
|
||||
afero.Fs
|
||||
|
||||
dir string
|
||||
}
|
||||
|
||||
//nolint:ireturn // Open must return afero.File to satisfy afero.Fs.
|
||||
func (f unlistableDirFs) Open(name string) (afero.File, error) {
|
||||
if name == f.dir {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// TestCheckWithUnlistableDirectory has a directory under the base that
|
||||
// cannot be listed, followed by an unlisted file: both are warned about and
|
||||
// the check still passes, unless --no-extra-files is given.
|
||||
func TestCheckWithUnlistableDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mem := afero.NewMemMapFs()
|
||||
require.NoError(t, mem.MkdirAll("/testdir/locked", 0o755))
|
||||
writeTestFile(t, mem, testFile1, "hello")
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testManifest, testDir,
|
||||
}, mem)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
// Directories are searched in name order, so this comes after "locked".
|
||||
writeTestFile(t, mem, "/testdir/unlisted.txt", "unlisted")
|
||||
|
||||
fs := unlistableDirFs{Fs: mem, dir: "/testdir/locked"}
|
||||
|
||||
opts = testOpts([]string{testApp, cmdCheck, testFlagBase, testDir, testManifest}, fs)
|
||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
assert.Contains(t, testStderr(t, opts), os.ErrPermission.Error())
|
||||
assert.Contains(t, testStderr(t, opts), "unlisted.txt")
|
||||
|
||||
// Check WITHOUT --no-extra-files (should pass - extra files ignored)
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
||||
testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode,
|
||||
"check without --no-extra-files should ignore extra files")
|
||||
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
assert.Contains(t, testStderr(t, opts), "unlisted.txt")
|
||||
}
|
||||
|
||||
func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
|
||||
@@ -455,7 +856,7 @@ func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
|
||||
assert.True(t, exists, "output file should exist")
|
||||
|
||||
// Verify temp file does NOT exist
|
||||
tmpExists, err := afero.Exists(fs, testOutputTmp)
|
||||
tmpExists, err := afero.Exists(fs, manifestTempPath(testOutput))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, tmpExists,
|
||||
"temp file should not exist after successful generation")
|
||||
@@ -487,7 +888,7 @@ func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) {
|
||||
"manifest should be overwritten")
|
||||
|
||||
// Verify temp file does NOT exist
|
||||
tmpExists, err := afero.Exists(fs, testOutputTmp)
|
||||
tmpExists, err := afero.Exists(fs, manifestTempPath(testOutput))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, tmpExists,
|
||||
"temp file should not exist after successful generation")
|
||||
@@ -516,6 +917,102 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
||||
assert.Equal(t, "existing", string(content), "original file should be preserved")
|
||||
}
|
||||
|
||||
// manifestPaths returns the file paths listed by the manifest at path.
|
||||
func manifestPaths(t *testing.T, fs afero.Fs, path string) []string {
|
||||
t.Helper()
|
||||
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: path,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
paths := make([]string, 0, len(manifest.Files()))
|
||||
for _, f := range manifest.Files() {
|
||||
paths = append(paths, f.GetPath())
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// TestGenerateLeavesOutputOutOfListing overwrites an output file inside the
|
||||
// scanned tree with --force: the old file is not listed, even when the tree
|
||||
// is named through a symlink, while a file named index.mf in a subdirectory
|
||||
// still is. The output file is recognized by file identity, which needs
|
||||
// the real filesystem.
|
||||
func TestGenerateLeavesOutputOutOfListing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Paths are relative to a temp dir holding data/tree and link, a
|
||||
// symlink to data.
|
||||
for name, tc := range map[string]struct{ input, output string }{
|
||||
"default name": {"data/tree", "data/tree/index.mf"},
|
||||
"other name in a subdirectory": {"data/tree", "data/tree/sub/listing.mf"},
|
||||
"tree named through a symlink": {"link/tree", "data/tree/index.mf"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
tree := filepath.Join(root, "data", "tree")
|
||||
output := filepath.Join(root, tc.output)
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(tree, "sub"), 0o750))
|
||||
require.NoError(t,
|
||||
os.Symlink(filepath.Join(root, "data"), filepath.Join(root, "link")))
|
||||
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
|
||||
writeTestFile(t, fs, filepath.Join(tree, "sub", "index.mf"), "an ordinary file")
|
||||
writeTestFile(t, fs, output, "previous manifest")
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "--force", "-o", output, filepath.Join(root, tc.input),
|
||||
}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
assert.ElementsMatch(t, []string{testFileTxt, "sub/index.mf"},
|
||||
manifestPaths(t, fs, output))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDefaultOutputLeftOutOfListing runs gen with --force and no
|
||||
// other arguments, so it scans the current directory and writes the
|
||||
// relative path index.mf: the index.mf already there is not listed.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestGenerateDefaultOutputLeftOutOfListing(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
writeTestFile(t, fs, testFileTxt, "hello")
|
||||
writeTestFile(t, fs, "index.mf", "previous manifest")
|
||||
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "--force"}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, "index.mf"))
|
||||
}
|
||||
|
||||
// TestGenerateLeavesLeftoverTempFileOutOfListing runs gen where an
|
||||
// interrupted run left its temp file beside the output: the leftover is
|
||||
// not listed, and gen does not fail when it overwrites it.
|
||||
func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
output := filepath.Join(root, "index.mf")
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||
writeTestFile(t, fs, manifestTempPath(output), "part of a manifest")
|
||||
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", output, root}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output))
|
||||
}
|
||||
|
||||
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -538,7 +1035,7 @@ func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||
exists, _ := afero.Exists(fs, testOutput)
|
||||
assert.True(t, exists, "output file should exist")
|
||||
|
||||
tmpExists, _ := afero.Exists(fs, testOutputTmp)
|
||||
tmpExists, _ := afero.Exists(fs, manifestTempPath(testOutput))
|
||||
assert.False(t, tmpExists, "temp file should be cleaned up")
|
||||
|
||||
// Verify manifest is valid (not empty)
|
||||
@@ -604,7 +1101,7 @@ func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) {
|
||||
"output file should not exist after failed generation (atomic write)")
|
||||
|
||||
// Temp file should also not exist
|
||||
tmpExists, _ := afero.Exists(baseFs, testOutputTmp)
|
||||
tmpExists, _ := afero.Exists(baseFs, manifestTempPath(testOutput))
|
||||
assert.False(t, tmpExists,
|
||||
"temp file should be cleaned up after failed generation")
|
||||
}
|
||||
@@ -673,6 +1170,22 @@ func TestGenerateValidatesInputPaths(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFlagAfterArgumentIsArgument asserts that flags are read only before a
|
||||
// command's first argument: after it, -v is a path, not the verbose flag.
|
||||
func TestFlagAfterArgumentIsArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "content")
|
||||
|
||||
opts := testOpts([]string{testApp, cmdGenerate, testDir, "-v"}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 1, exitCode)
|
||||
assert.Contains(t, testStderr(t, opts), "path does not exist: -v")
|
||||
}
|
||||
|
||||
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+78
-48
@@ -4,7 +4,6 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
urfcli "github.com/urfave/cli/v2"
|
||||
urfcli "github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
@@ -61,7 +60,11 @@ func unsignedChecker(t *testing.T) *mfer.Checker {
|
||||
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("/d/index.mf", "/d", fs)
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: "/d/index.mf",
|
||||
BasePath: "/d",
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, chk.IsSigned())
|
||||
|
||||
@@ -74,7 +77,7 @@ func TestNoManifestFoundMessage(t *testing.T) {
|
||||
_, 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 and .index.mf)")
|
||||
"no manifest found in /tmp/x (looked for index.mf)")
|
||||
}
|
||||
|
||||
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
@@ -83,7 +86,8 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
t.Run("invalid fingerprint length", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := verifyRequiredSigner(unsignedChecker(t), "12345678")
|
||||
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")
|
||||
@@ -92,7 +96,8 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
t.Run("manifest not signed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := verifyRequiredSigner(unsignedChecker(t), msgFpA)
|
||||
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")
|
||||
@@ -105,23 +110,25 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
// 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 // signedChecker calls t.Setenv, which bars t.Parallel
|
||||
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
|
||||
func TestSignerMismatchMessage(t *testing.T) {
|
||||
chk := signedChecker(t)
|
||||
chk := signedChecker(t,
|
||||
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
|
||||
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = verifyRequiredSigner(chk, msgFpB)
|
||||
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)
|
||||
}
|
||||
|
||||
// signedChecker builds a Checker over a manifest signed by a throwaway GPG
|
||||
// key generated in a temporary GNUPGHOME.
|
||||
func signedChecker(t *testing.T) *mfer.Checker {
|
||||
// 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")
|
||||
@@ -153,19 +160,31 @@ func signedChecker(t *testing.T) *mfer.Checker {
|
||||
b := mfer.NewBuilder()
|
||||
b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
|
||||
|
||||
content := []byte("signed file")
|
||||
_, err = b.AddFile("f.txt", mfer.FileSize(len(content)), mfer.ModTime{},
|
||||
bytes.NewReader(content), nil)
|
||||
require.NoError(t, err)
|
||||
for path, content := range files {
|
||||
_, err = b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)),
|
||||
mfer.ModTime{}, bytes.NewReader(content), nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
require.NoError(t, b.Build(&buf))
|
||||
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", buf.Bytes(), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644))
|
||||
|
||||
chk, err := mfer.NewChecker("/index.mf", "/", fs)
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: "/index.mf",
|
||||
BasePath: "/",
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, chk.IsSigned())
|
||||
|
||||
@@ -175,13 +194,17 @@ func signedChecker(t *testing.T) *mfer.Checker {
|
||||
func TestPathDoesNotExistMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
||||
require.NoError(t, set.Parse([]string{"nope"}))
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||
ctx := urfcli.NewContext(nil, set, nil)
|
||||
cmd := &urfcli.Command{
|
||||
Name: cmdGenerate,
|
||||
Action: func(_ context.Context, c *urfcli.Command) error {
|
||||
_, err := mfa.collectInputPaths(c.Args())
|
||||
|
||||
_, err := mfa.collectInputPaths(ctx.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")
|
||||
}
|
||||
@@ -194,18 +217,22 @@ func TestOutputFileExistsMessage(t *testing.T) {
|
||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644))
|
||||
|
||||
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
||||
set.String("output", "", "")
|
||||
set.Bool("force", false, "")
|
||||
require.NoError(t, set.Parse([]string{"/d"}))
|
||||
require.NoError(t, set.Set("output", "/out.mf"))
|
||||
|
||||
mfa := &CLIApp{Fs: fs}
|
||||
ctx := urfcli.NewContext(nil, set, nil)
|
||||
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 mfa.generateManifestOperation(ctx) })
|
||||
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)")
|
||||
@@ -230,7 +257,7 @@ func TestUnknownCommandMessage(t *testing.T) {
|
||||
err := runLocked(func() error {
|
||||
mfa.run([]string{testApp})
|
||||
|
||||
return mfa.app.Run([]string{testApp, "bogus"})
|
||||
return mfa.app.Run(context.Background(), []string{testApp, "bogus"})
|
||||
})
|
||||
require.ErrorIs(t, err, errUnknownCommand)
|
||||
assert.EqualError(t, err, `unknown command "bogus"`)
|
||||
@@ -247,7 +274,7 @@ func TestManifestLoaderHTTPStatusMessage(t *testing.T) {
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||
|
||||
_, err := mfa.openManifestReader(server.URL + "/foo.mf")
|
||||
_, 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")
|
||||
@@ -262,14 +289,15 @@ func TestFetchManifestHTTPStatusMessage(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
||||
require.NoError(t, set.Parse([]string{server.URL}))
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||
ctx := urfcli.NewContext(nil, set, nil)
|
||||
|
||||
cmd := mfa.fetchCommand()
|
||||
cmd.Action = mfa.fetchManifestOperation
|
||||
|
||||
// fetchManifestOperation logs to the process-global logger.
|
||||
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
|
||||
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")
|
||||
}
|
||||
@@ -283,8 +311,11 @@ func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := downloadFile(context.Background(), server.URL+"/x", "x",
|
||||
&mfer.MFFilePath{}, nil)
|
||||
// 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")
|
||||
}
|
||||
@@ -292,14 +323,13 @@ func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
||||
func TestURLRequiredMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
||||
require.NoError(t, set.Parse([]string{}))
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||
ctx := urfcli.NewContext(nil, set, nil)
|
||||
cmd := &urfcli.Command{Name: cmdFetch, Action: mfa.fetchManifestOperation}
|
||||
|
||||
// fetchManifestOperation logs to the process-global logger.
|
||||
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
|
||||
err := runLocked(func() error {
|
||||
return cmd.Run(context.Background(), []string{cmdFetch})
|
||||
})
|
||||
require.ErrorIs(t, err, errURLRequired)
|
||||
assert.EqualError(t, err, "URL argument required")
|
||||
}
|
||||
@@ -337,7 +367,7 @@ func TestSizeMismatchMessage(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
err := finishDownload("", "", "", 9, 10, nil, nil, nil, nil)
|
||||
require.ErrorIs(t, err, errSizeMismatch)
|
||||
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
@@ -19,19 +20,22 @@ type ExportEntry struct {
|
||||
Ctime *string `json:"ctime,omitempty"`
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) exportManifestOperation(ctx *cli.Context) error {
|
||||
pathOrURL, err := mfa.resolveManifestArg(ctx)
|
||||
func (mfa *CLIApp) exportManifestOperation(
|
||||
ctx context.Context, cmd *cli.Command,
|
||||
) error {
|
||||
pathOrURL, err := mfa.resolveManifestArg(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: %w", err)
|
||||
}
|
||||
|
||||
rc, err := mfa.openManifestReader(pathOrURL)
|
||||
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
manifest, err := mfer.NewManifestFromReader(rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: failed to parse manifest: %w", err)
|
||||
|
||||
+457
-127
@@ -7,17 +7,20 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
@@ -45,12 +48,33 @@ const (
|
||||
bpsPerGbps = 1e9
|
||||
bpsPerMbps = 1e6
|
||||
bpsPerKbps = 1e3
|
||||
|
||||
// httpTimeout is the default time limit for one HTTP request, from
|
||||
// connecting to reading the last byte of the body. It also bounds how
|
||||
// long one file may take to download; fetch's --timeout changes it.
|
||||
httpTimeout = 10 * time.Minute
|
||||
|
||||
// fetchAttempts is how many times fetch tries a request before it
|
||||
// gives up on a transient failure.
|
||||
fetchAttempts = 5
|
||||
|
||||
// firstRetryDelay is the longest fetch waits before its first retry.
|
||||
// The limit doubles for each retry after that; the wait itself is
|
||||
// random up to the limit.
|
||||
firstRetryDelay = time.Second
|
||||
|
||||
// maxRetryAfter is the longest wait a server's Retry-After header can
|
||||
// ask for. A server asking for longer fails the request at once.
|
||||
maxRetryAfter = time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
// errURLRequired indicates the fetch command was run without a URL
|
||||
// argument.
|
||||
errURLRequired = errors.New("URL argument required")
|
||||
// errInvalidTimeout indicates a fetch --timeout of zero or less, which
|
||||
// http.Client would take as no time limit at all.
|
||||
errInvalidTimeout = errors.New("--timeout must be greater than zero")
|
||||
// errEmptyPath indicates an empty file path in the manifest.
|
||||
errEmptyPath = errors.New("empty path")
|
||||
// errAbsolutePath indicates an absolute file path in the manifest.
|
||||
@@ -67,6 +91,10 @@ var (
|
||||
// errHashMismatch indicates a downloaded file whose hash matches no
|
||||
// manifest hash.
|
||||
errHashMismatch = errors.New("hash mismatch")
|
||||
// errNameClash indicates a manifest that lists a file where fetch
|
||||
// writes another file.
|
||||
errNameClash = errors.New(
|
||||
"manifest lists a file where fetch writes another file")
|
||||
)
|
||||
|
||||
// DownloadProgress reports the progress of a single file download.
|
||||
@@ -78,24 +106,109 @@ type DownloadProgress struct {
|
||||
ETA time.Duration // Estimated time to completion
|
||||
}
|
||||
|
||||
// httpGet issues a GET request for the given URL using the provided
|
||||
// context and returns the response. The caller must close the body.
|
||||
// retryingClient is the HTTP client fetch makes every request with. It
|
||||
// waits up to firstDelay, which must be positive, before its first retry
|
||||
// of a transient failure. Tests shorten both the client's timeout and
|
||||
// firstDelay.
|
||||
type retryingClient struct {
|
||||
client *http.Client
|
||||
firstDelay time.Duration
|
||||
}
|
||||
|
||||
// get issues a GET for rawURL and passes a 200 OK response to use.
|
||||
//
|
||||
// Errors are returned unwrapped: this helper replaced direct http.Get
|
||||
// calls, and each caller already supplies its own context string, so
|
||||
// adding one here would change user-visible messages.
|
||||
func httpGet(ctx context.Context, fileURL string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
// A connection error, a timeout, or a 5xx or 429 status is retried, up to
|
||||
// fetchAttempts tries in all. Before each retry it waits a random time up
|
||||
// to a limit that doubles each time, unless the server's Retry-After
|
||||
// header says how long to wait. Any other failure, an error from use
|
||||
// included, is returned at once and unwrapped; a non-OK status is
|
||||
// returned as errHTTPStatus. use must start over each time it is called,
|
||||
// since a retry calls it again with a new response.
|
||||
func (c retryingClient) get(
|
||||
ctx context.Context, rawURL string, use func(*http.Response) error,
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
delay := c.firstDelay
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
// Wait a random time up to delay, so that clients that failed
|
||||
// together do not all retry together.
|
||||
wait := rand.N(delay) //nolint:gosec // G404: jitter, not a secret
|
||||
|
||||
var retry bool
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
retry = isConnectionError(err)
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
err = use(resp)
|
||||
retry = isConnectionError(err)
|
||||
|
||||
_ = resp.Body.Close()
|
||||
default:
|
||||
_ = resp.Body.Close()
|
||||
|
||||
err = fmt.Errorf("%w %d", errHTTPStatus, resp.StatusCode)
|
||||
retry = resp.StatusCode >= http.StatusInternalServerError ||
|
||||
resp.StatusCode == http.StatusTooManyRequests
|
||||
|
||||
after, ok := retryAfter(resp.Header.Get("Retry-After"))
|
||||
if ok {
|
||||
wait = after
|
||||
retry = retry && after <= maxRetryAfter
|
||||
}
|
||||
}
|
||||
|
||||
if !retry || attempt == fetchAttempts || ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Warnf("%s: %s, retrying in %s", rawURL, err, wait.Round(time.Millisecond))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
|
||||
// isConnectionError reports whether err is a connection error or a
|
||||
// timeout: a connection that could not be made, was reset, or closed
|
||||
// early, or a request that ran out of time.
|
||||
func isConnectionError(err error) bool {
|
||||
var (
|
||||
opErr *net.OpError
|
||||
netErr net.Error
|
||||
)
|
||||
|
||||
return errors.As(err, &opErr) ||
|
||||
(errors.As(err, &netErr) && netErr.Timeout()) ||
|
||||
errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
|
||||
}
|
||||
|
||||
// retryAfter returns the wait a Retry-After header value asks for, given
|
||||
// either in seconds or as an HTTP date. ok is false if there is none.
|
||||
func retryAfter(value string) (time.Duration, bool) {
|
||||
seconds, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
return time.Duration(seconds) * time.Second, true
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
when, err := http.ParseTime(value)
|
||||
if err == nil {
|
||||
return time.Until(when), true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// reportDownloadProgress renders download progress until the channel
|
||||
@@ -119,96 +232,142 @@ func reportDownloadProgress(progress <-chan DownloadProgress, done chan<- struct
|
||||
}
|
||||
|
||||
// manifestBaseURL returns the URL of the directory containing the
|
||||
// manifest, with a trailing slash.
|
||||
// manifest.
|
||||
func manifestBaseURL(manifestURL string) (*url.URL, error) {
|
||||
baseURL, err := url.Parse(manifestURL)
|
||||
parsed, err := url.Parse(manifestURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: invalid manifest URL: %w", err)
|
||||
}
|
||||
|
||||
baseURL.Path = path.Dir(baseURL.Path)
|
||||
if !strings.HasSuffix(baseURL.Path, "/") {
|
||||
baseURL.Path += "/"
|
||||
}
|
||||
|
||||
return baseURL, nil
|
||||
// JoinPath cleans the path it builds, so ".." drops the manifest's
|
||||
// file name.
|
||||
return parsed.JoinPath(".."), nil
|
||||
}
|
||||
|
||||
// downloadManifestFiles downloads every file in the manifest, reporting
|
||||
// progress on the progress channel.
|
||||
// downloadManifestFiles downloads every file in the manifest into dest,
|
||||
// reporting progress on the progress channel. A file already present in
|
||||
// dest is skipped. It returns how many files it downloaded and their
|
||||
// total size.
|
||||
func downloadManifestFiles(
|
||||
ctx context.Context,
|
||||
client retryingClient,
|
||||
baseURL *url.URL,
|
||||
dest string,
|
||||
files []*mfer.MFFilePath,
|
||||
progress chan<- DownloadProgress,
|
||||
) error {
|
||||
) (int, int64, error) {
|
||||
var (
|
||||
downloaded int
|
||||
downloadedBytes int64
|
||||
)
|
||||
|
||||
for _, f := range files {
|
||||
// Sanitize the path to prevent path traversal attacks
|
||||
localPath, err := sanitizePath(f.GetPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path in manifest: %w", err)
|
||||
return 0, 0, fmt.Errorf("invalid path in manifest: %w", err)
|
||||
}
|
||||
|
||||
fileURL := baseURL.String() + encodeFilePath(f.GetPath())
|
||||
if alreadyPresent(dest, localPath, f) {
|
||||
log.Infof("skipping %s: already present", f.GetPath())
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// JoinPath takes escaped path text, so a name such as "100%.txt"
|
||||
// must be escaped first.
|
||||
fileURL := baseURL.JoinPath(encodeFilePath(f.GetPath())).String()
|
||||
log.Infof("fetching %s", f.GetPath())
|
||||
|
||||
err = downloadFile(ctx, fileURL, localPath, f, progress)
|
||||
err = downloadFile(ctx, client, fileURL, dest, localPath, f, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download %s: %w", f.GetPath(), err)
|
||||
return 0, 0, fmt.Errorf("failed to download %s: %w", f.GetPath(), err)
|
||||
}
|
||||
|
||||
downloaded++
|
||||
downloadedBytes += f.GetSize()
|
||||
}
|
||||
|
||||
return nil
|
||||
return downloaded, downloadedBytes, nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||
// alreadyPresent reports whether localPath under dest is a regular file
|
||||
// with the size and one of the hashes the manifest lists for entry. It
|
||||
// hashes the whole file, since a matching size alone would accept a
|
||||
// corrupted or partly written one. A file it cannot read, or reaches only
|
||||
// through a symlink, is not present: fetch downloads it, and the download
|
||||
// reports the problem.
|
||||
func alreadyPresent(dest, localPath string, entry *mfer.MFFilePath) bool {
|
||||
if checkNoSymlinks(dest, localPath) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
path := filepath.Join(dest, localPath)
|
||||
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != entry.GetSize() {
|
||||
return false
|
||||
}
|
||||
|
||||
// G304: localPath is a relative path that sanitizePath keeps inside
|
||||
// dest as text, and checkNoSymlinks just found no symlink in it.
|
||||
f, err := os.Open(path) //nolint:gosec // G304: see comment above
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
|
||||
_, err = io.Copy(h, f)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return verifyDownloadedHash(h.Sum(nil), entry) == nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) fetchManifestOperation(
|
||||
ctx context.Context, cmd *cli.Command,
|
||||
) error {
|
||||
log.Debug("fetchManifestOperation()")
|
||||
|
||||
if ctx.Args().Len() == 0 {
|
||||
if cmd.Args().Len() == 0 {
|
||||
return errURLRequired
|
||||
}
|
||||
|
||||
inputURL := ctx.Args().Get(0)
|
||||
timeout := cmd.Duration(flagTimeout)
|
||||
if timeout <= 0 {
|
||||
return errInvalidTimeout
|
||||
}
|
||||
|
||||
manifestURL, err := resolveManifestURL(inputURL)
|
||||
manifestURL, err := resolveManifestURL(cmd.Args().Get(0))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("fetching manifest from %s", manifestURL)
|
||||
client := retryingClient{
|
||||
client: &http.Client{Timeout: timeout},
|
||||
firstDelay: firstRetryDelay,
|
||||
}
|
||||
|
||||
// Fetch manifest
|
||||
resp, err := httpGet(ctx.Context, manifestURL)
|
||||
manifestData, files, err := fetchManifest(ctx, cmd, client, manifestURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch manifest: %w %d",
|
||||
errHTTPStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse manifest
|
||||
manifest, err := mfer.NewManifestFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
files := manifest.Files()
|
||||
log.Infof("manifest contains %d files", len(files))
|
||||
|
||||
// Compute base URL (directory containing manifest)
|
||||
baseURL, err := manifestBaseURL(manifestURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate total bytes to download
|
||||
var totalBytes int64
|
||||
for _, f := range files {
|
||||
totalBytes += f.GetSize()
|
||||
dest := cmd.String(flagDest)
|
||||
|
||||
err = os.MkdirAll(dest, dirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination directory %s: %w", dest, err)
|
||||
}
|
||||
|
||||
// Create progress channel and start progress reporter goroutine
|
||||
@@ -221,7 +380,8 @@ func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||
startTime := time.Now()
|
||||
|
||||
// Download each file
|
||||
dlErr := downloadManifestFiles(ctx.Context, baseURL, files, progress)
|
||||
downloaded, downloadedBytes, dlErr := downloadManifestFiles(
|
||||
ctx, client, baseURL, dest, files, progress)
|
||||
|
||||
close(progress)
|
||||
<-done
|
||||
@@ -230,15 +390,170 @@ func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||
return dlErr
|
||||
}
|
||||
|
||||
// Saved only now that every file is in place and verified, so that
|
||||
// "mfer check" can verify the tree later.
|
||||
err = saveManifest(dest, manifestData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save manifest: %w", err)
|
||||
}
|
||||
|
||||
// Print summary
|
||||
elapsed := time.Since(startTime)
|
||||
avgBytesPerSec := float64(totalBytes) / elapsed.Seconds()
|
||||
avgBytesPerSec := float64(downloadedBytes) / elapsed.Seconds()
|
||||
avgRate := formatBitrate(avgBytesPerSec * bitsPerByte)
|
||||
log.Infof("downloaded %d files (%s) in %.1fs (%s avg)",
|
||||
len(files),
|
||||
humanize.IBytes(safeUint64(totalBytes)),
|
||||
log.Infof("downloaded %d files (%s) in %.1fs (%s avg), skipped %d already present",
|
||||
downloaded,
|
||||
humanize.IBytes(safeUint64(downloadedBytes)),
|
||||
elapsed.Seconds(),
|
||||
avgRate)
|
||||
avgRate,
|
||||
len(files)-downloaded)
|
||||
log.Infof("saved manifest to %s", filepath.Join(dest, defaultManifestName))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchManifest downloads the manifest at manifestURL and parses it,
|
||||
// enforcing --require-signature if it is given and refusing a manifest
|
||||
// that lists a file where fetch writes another. It returns the manifest as
|
||||
// downloaded, to be saved once the files are in place, and the files it
|
||||
// lists.
|
||||
func fetchManifest(
|
||||
ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string,
|
||||
) ([]byte, []*mfer.MFFilePath, error) {
|
||||
log.Infof("fetching manifest from %s", manifestURL)
|
||||
|
||||
// Read the whole manifest before parsing it, so that a connection
|
||||
// lost partway through is retried rather than reported as a bad
|
||||
// manifest.
|
||||
var manifestData []byte
|
||||
|
||||
err := client.get(ctx, manifestURL, func(resp *http.Response) error {
|
||||
var readErr error
|
||||
|
||||
manifestData, readErr = io.ReadAll(resp.Body)
|
||||
|
||||
return readErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
// Parse manifest
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if requiredSigner != "" {
|
||||
err = verifyFetchedSigner(ctx, manifestData, requiredSigner)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
files := manifest.Files()
|
||||
|
||||
err = checkNoNameClash(files)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
log.Infof("manifest contains %d files", len(files))
|
||||
|
||||
return manifestData, files, nil
|
||||
}
|
||||
|
||||
// checkNoNameClash returns an error if files lists a file, or a directory
|
||||
// a file is in, under a name where fetch writes another file: the temp
|
||||
// file it downloads a listed file to, or, at the top of the tree, the
|
||||
// saved manifest or its temp file. fetch would remove or replace what is
|
||||
// listed there, or fail partway, leaving a tree check rejects. Names are
|
||||
// compared ignoring case, since on a case-insensitive filesystem INDEX.MF
|
||||
// and index.mf are one file.
|
||||
func checkNoNameClash(files []*mfer.MFFilePath) error {
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
// written maps each name fetch writes, other than the listed files
|
||||
// themselves, in lower case, to the file it writes there.
|
||||
written := map[string]string{
|
||||
defaultManifestName: "the saved manifest",
|
||||
tempPathFor(defaultManifestName): "the saved manifest's temp file",
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
tmpPath := tempPathFor(filepath.Clean(f.GetPath()))
|
||||
written[strings.ToLower(tmpPath)] = "the temp file for " + f.GetPath()
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
// Look up each directory on the file's path, then the file itself.
|
||||
parts := strings.Split(strings.ToLower(filepath.Clean(f.GetPath())), sep)
|
||||
|
||||
for i := range parts {
|
||||
what, ok := written[strings.Join(parts[:i+1], sep)]
|
||||
if ok {
|
||||
return fmt.Errorf("%w: %s (%s)", errNameClash, f.GetPath(), what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFetchedSigner enforces --require-signature on the fetched manifest
|
||||
// exactly as check does. verifyRequiredSigner takes a Checker, which loads
|
||||
// its manifest from a file, so the manifest is handed to it as a file in
|
||||
// memory.
|
||||
func verifyFetchedSigner(
|
||||
ctx context.Context, manifestData []byte, requiredSigner string,
|
||||
) error {
|
||||
memFs := afero.NewMemMapFs()
|
||||
manifestPath := "/" + defaultManifestName
|
||||
|
||||
err := afero.WriteFile(memFs, manifestPath, manifestData, filePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: manifestPath,
|
||||
BasePath: "/",
|
||||
Fs: memFs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load manifest: %w", err)
|
||||
}
|
||||
|
||||
return verifyRequiredSigner(ctx, chk, requiredSigner)
|
||||
}
|
||||
|
||||
// saveManifest writes the fetched manifest into dest under the default
|
||||
// manifest name, the way fetch writes every file: to a new temp file that
|
||||
// is then renamed into place.
|
||||
func saveManifest(dest string, manifestData []byte) error {
|
||||
tmpPath := tempPathFor(defaultManifestName)
|
||||
|
||||
out, err := createTempFile(dest, tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, writeErr := out.Write(manifestData)
|
||||
closeErr := out.Close()
|
||||
|
||||
err = errors.Join(writeErr, closeErr)
|
||||
if err == nil {
|
||||
err = moveIntoPlace(dest, tmpPath, defaultManifestName)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = os.Remove(filepath.Join(dest, tmpPath))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -282,14 +597,15 @@ func sanitizePath(p string) (string, error) {
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// checkNoSymlinks returns an error if any part of the relative path p
|
||||
// already exists as a symlink. sanitizePath checks p only as text, so
|
||||
// without this a symlink inside the target directory could send a write
|
||||
// to p outside of it. Parts that do not exist yet are fine: fetch creates
|
||||
// them as plain directories and files. Call it immediately before each
|
||||
// write: a symlink created after it returns is not caught.
|
||||
func checkNoSymlinks(p string) error {
|
||||
current := ""
|
||||
// checkNoSymlinks returns an error if any part of p, a path relative to
|
||||
// dest, already exists under dest as a symlink. dest itself is the user's
|
||||
// choice and may be one. sanitizePath checks p only as text, so without
|
||||
// this a symlink inside dest could send a write to p outside of it. Parts
|
||||
// that do not exist yet are fine: fetch creates them as plain directories
|
||||
// and files. Call it immediately before each write: a symlink created
|
||||
// after it returns is not caught.
|
||||
func checkNoSymlinks(dest, p string) error {
|
||||
current := dest
|
||||
|
||||
for _, part := range strings.Split(p, string(filepath.Separator)) {
|
||||
current = filepath.Join(current, part)
|
||||
@@ -313,7 +629,7 @@ func checkNoSymlinks(p string) error {
|
||||
|
||||
// resolveManifestURL takes a URL and returns the manifest URL.
|
||||
// If the URL already ends with .mf, it's returned as-is.
|
||||
// Otherwise, index.mf is appended.
|
||||
// Otherwise, the default manifest name is appended.
|
||||
func resolveManifestURL(inputURL string) (string, error) {
|
||||
parsed, err := url.Parse(inputURL)
|
||||
if err != nil {
|
||||
@@ -325,15 +641,7 @@ func resolveManifestURL(inputURL string) (string, error) {
|
||||
return inputURL, nil
|
||||
}
|
||||
|
||||
// Ensure path ends with /
|
||||
if !strings.HasSuffix(parsed.Path, "/") {
|
||||
parsed.Path += "/"
|
||||
}
|
||||
|
||||
// Append index.mf
|
||||
parsed.Path += "index.mf"
|
||||
|
||||
return parsed.String(), nil
|
||||
return parsed.JoinPath(defaultManifestName).String(), nil
|
||||
}
|
||||
|
||||
// progressWriter wraps an io.Writer and reports progress to a channel.
|
||||
@@ -437,12 +745,14 @@ func verifyDownloadedHash(digest []byte, entry *mfer.MFFilePath) error {
|
||||
return errHashMismatch
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a local file path with hash verification.
|
||||
// It downloads to a temporary file, verifies the hash, then renames to the final path.
|
||||
// Progress is reported via the progress channel.
|
||||
// downloadFile downloads a URL to localPath, a path relative to dest, with
|
||||
// hash verification. It downloads to a temporary file, verifies the hash,
|
||||
// then renames to the final path. Progress is reported via the progress
|
||||
// channel.
|
||||
func downloadFile(
|
||||
ctx context.Context,
|
||||
fileURL, localPath string,
|
||||
client retryingClient,
|
||||
fileURL, dest, localPath string,
|
||||
entry *mfer.MFFilePath,
|
||||
progress chan<- DownloadProgress,
|
||||
) error {
|
||||
@@ -456,11 +766,13 @@ func downloadFile(
|
||||
// Create parent directories if needed
|
||||
dir := filepath.Dir(localPath)
|
||||
if dir != "" && dir != "." {
|
||||
err = checkNoSymlinks(dir)
|
||||
err = checkNoSymlinks(dest, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir = filepath.Join(dest, dir)
|
||||
|
||||
err = os.MkdirAll(dir, dirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
@@ -469,18 +781,65 @@ func downloadFile(
|
||||
|
||||
tmpPath := tempPathFor(localPath)
|
||||
|
||||
// Fetch file
|
||||
resp, err := httpGet(ctx, fileURL)
|
||||
return client.get(ctx, fileURL, func(resp *http.Response) error {
|
||||
return saveResponse(resp, dest, tmpPath, localPath, entry, progress)
|
||||
})
|
||||
}
|
||||
|
||||
// createTempFile creates tmpPath, a path relative to dest, as a new empty
|
||||
// file.
|
||||
func createTempFile(dest, tmpPath string) (*os.File, error) {
|
||||
err := checkNoSymlinks(dest, tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP request failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
path := filepath.Join(dest, tmpPath)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w %d", errHTTPStatus, resp.StatusCode)
|
||||
// Remove whatever is at tmpPath, such as a leftover from an
|
||||
// interrupted run, rather than write into it: it may be a hard link
|
||||
// to a file outside dest, and removing a hard link removes only this
|
||||
// name. If the removal fails, O_EXCL below makes the create fail.
|
||||
_ = os.Remove(path)
|
||||
|
||||
// Create the temp file only if nothing is at tmpPath (O_EXCL).
|
||||
//
|
||||
// G304: tmpPath is a relative path that sanitizePath keeps inside dest
|
||||
// as text, and checkNoSymlinks just found no symlink in it.
|
||||
out, err := os.OpenFile( //nolint:gosec // G304: see comment above
|
||||
path, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// moveIntoPlace renames tmpPath to localPath, both relative to dest.
|
||||
func moveIntoPlace(dest, tmpPath, localPath string) error {
|
||||
err := checkNoSymlinks(dest, localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.Rename(filepath.Join(dest, tmpPath), filepath.Join(dest, localPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveResponse writes resp's body to tmpPath, verifies it against entry,
|
||||
// and renames it to localPath, both paths relative to dest. It starts a
|
||||
// new temp file each time and removes it on failure, so a retry after a
|
||||
// failed try never appends to or keeps a partial file.
|
||||
func saveResponse(
|
||||
resp *http.Response,
|
||||
dest, tmpPath, localPath string,
|
||||
entry *mfer.MFFilePath,
|
||||
progress chan<- DownloadProgress,
|
||||
) error {
|
||||
// Determine expected size
|
||||
expectedSize := entry.GetSize()
|
||||
|
||||
@@ -489,29 +848,11 @@ func downloadFile(
|
||||
totalBytes = expectedSize
|
||||
}
|
||||
|
||||
err = checkNoSymlinks(tmpPath)
|
||||
out, err := createTempFile(dest, tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove whatever is at tmpPath, such as a leftover from an
|
||||
// interrupted run, rather than write into it: it may be a hard link
|
||||
// to a file outside the target directory, and removing a hard link
|
||||
// removes only this name. If the removal fails, O_EXCL below makes
|
||||
// the create fail.
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
// Create the temp file only if nothing is at tmpPath (O_EXCL).
|
||||
//
|
||||
// G304: tmpPath is a relative path that sanitizePath keeps inside the
|
||||
// target directory as text, and checkNoSymlinks just found no symlink
|
||||
// in it.
|
||||
out, err := os.OpenFile( //nolint:gosec // G304: see comment above
|
||||
tmpPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
// Set up hash computation
|
||||
h := sha256.New()
|
||||
|
||||
@@ -531,10 +872,10 @@ func downloadFile(
|
||||
closeErr := out.Close()
|
||||
|
||||
err = finishDownload(
|
||||
tmpPath, localPath, written, expectedSize, h.Sum(nil), entry,
|
||||
dest, tmpPath, localPath, written, expectedSize, h.Sum(nil), entry,
|
||||
copyErr, closeErr)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
_ = os.Remove(filepath.Join(dest, tmpPath))
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -545,7 +886,7 @@ func downloadFile(
|
||||
// finishDownload validates the copy result, verifies size and hash, and
|
||||
// moves the temp file into place. On error the caller removes tmpPath.
|
||||
func finishDownload(
|
||||
tmpPath, localPath string,
|
||||
dest, tmpPath, localPath string,
|
||||
written, expectedSize int64,
|
||||
digest []byte,
|
||||
entry *mfer.MFFilePath,
|
||||
@@ -571,16 +912,5 @@ func finishDownload(
|
||||
return err
|
||||
}
|
||||
|
||||
err = checkNoSymlinks(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename temp file to final path
|
||||
err = os.Rename(tmpPath, localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return moveIntoPlace(dest, tmpPath, localPath)
|
||||
}
|
||||
|
||||
+858
-26
@@ -4,12 +4,20 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -214,6 +222,68 @@ func fetchTestHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// manifestOf scans a tree holding files and returns its manifest bytes.
|
||||
func manifestOf(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
for p, content := range files {
|
||||
require.NoError(t, sourceFs.MkdirAll(filepath.Dir("/"+p), 0o755))
|
||||
require.NoError(t, afero.WriteFile(sourceFs, "/"+p, content, 0o644))
|
||||
}
|
||||
|
||||
return scanToManifest(t, sourceFs)
|
||||
}
|
||||
|
||||
// filesUnder returns the content of every file under dir, by its path
|
||||
// relative to dir.
|
||||
func filesUnder(t *testing.T, dir string) map[string][]byte {
|
||||
t.Helper()
|
||||
|
||||
files := map[string][]byte{}
|
||||
|
||||
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path) //nolint:gosec // test-controlled path
|
||||
files[filepath.ToSlash(rel)] = content
|
||||
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// testClient returns the client tests download with. Its retries wait
|
||||
// milliseconds rather than seconds.
|
||||
func testClient() retryingClient {
|
||||
return retryingClient{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
firstDelay: time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// getNothing calls client.get for rawURL with a use that reads nothing,
|
||||
// under a context that expires after timeout. It holds runMu, since get
|
||||
// logs each retry to the process-global logger, and starts the timeout
|
||||
// only once it has the lock.
|
||||
func getNothing(client retryingClient, rawURL string, timeout time.Duration) error {
|
||||
return runLocked(func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
return client.get(ctx, rawURL, func(*http.Response) error { return nil })
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchFromHTTP(t *testing.T) {
|
||||
// Create source filesystem with test files
|
||||
@@ -266,7 +336,8 @@ func TestFetchFromHTTP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
fileURL := baseURL + f.GetPath()
|
||||
err = downloadFile(context.Background(), fileURL, localPath, f, progress)
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
fileURL, ".", localPath, f, progress)
|
||||
require.NoError(t, err, "failed to download %s", f.GetPath())
|
||||
}
|
||||
|
||||
@@ -313,8 +384,8 @@ func TestFetchHashMismatch(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
// Try to download - should fail with hash mismatch
|
||||
err = downloadFile(context.Background(),
|
||||
server.URL+"/file.txt", testFileTxt, files[0], nil)
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/file.txt", ".", testFileTxt, files[0], nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mismatch")
|
||||
|
||||
@@ -359,8 +430,8 @@ func TestFetchSizeMismatch(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
// Try to download - should fail with size mismatch
|
||||
err = downloadFile(context.Background(),
|
||||
server.URL+"/file.txt", testFileTxt, files[0], nil)
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/file.txt", ".", testFileTxt, files[0], nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "size mismatch")
|
||||
|
||||
@@ -417,8 +488,8 @@ func TestFetchProgress(t *testing.T) {
|
||||
}()
|
||||
|
||||
// Download
|
||||
err = downloadFile(context.Background(),
|
||||
server.URL+"/large.txt", "large.txt", files[0], progress)
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/large.txt", ".", "large.txt", files[0], progress)
|
||||
close(progress)
|
||||
<-done
|
||||
|
||||
@@ -441,24 +512,51 @@ func TestFetchProgress(t *testing.T) {
|
||||
assert.Equal(t, content, downloaded)
|
||||
}
|
||||
|
||||
// TestFetchRefusesSymlinks runs fetch into a destination directory that
|
||||
// holds a symlink pointing outside it, in each of the three places fetch
|
||||
// writes: a parent directory, the temp file, and the file itself, which
|
||||
// the temp file is renamed onto; and once as a directory inside a plain
|
||||
// directory. The fetch must fail and nothing outside may change.
|
||||
// TestFetchRefusesSymlinks runs fetch with --dest naming a directory other
|
||||
// than the current one, which holds a symlink pointing outside it, in each
|
||||
// place fetch writes: a parent directory, the temp file, the file itself,
|
||||
// which the temp file is renamed onto, and the saved manifest's temp file
|
||||
// and final name; and once as a directory inside a plain directory. The
|
||||
// fetch must fail, and neither the outside directory nor the current one
|
||||
// may change.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchRefusesSymlinks(t *testing.T) {
|
||||
// What a link standing for a file points to: a file outside that does
|
||||
// not exist yet.
|
||||
const newFile = "new.txt"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entry string // the manifest's only file
|
||||
link string // symlink placed in the destination directory
|
||||
target string // what link points to, relative to the outside directory
|
||||
name string
|
||||
entry string // the manifest's only file
|
||||
link string // symlink placed in the destination directory
|
||||
target string // what link points to, relative to the outside directory
|
||||
failure string // what fetch reports it was doing when it found link
|
||||
}{
|
||||
{"parent directory", "sub/deeper/file.txt", "sub", "."},
|
||||
{"directory inside a plain directory", "docs/data/passwd", "docs/data", "."},
|
||||
{"temp file", testFileTxt, ".file.txt.tmp", "new.txt"},
|
||||
{"file", testFileTxt, testFileTxt, "new.txt"},
|
||||
{
|
||||
"parent directory", "sub/deeper/file.txt", "sub", ".",
|
||||
"failed to download sub/deeper/file.txt",
|
||||
},
|
||||
{
|
||||
"directory inside a plain directory", "docs/data/passwd", "docs/data", ".",
|
||||
"failed to download docs/data/passwd",
|
||||
},
|
||||
{
|
||||
"temp file", testFileTxt, ".file.txt.tmp", newFile,
|
||||
"failed to download " + testFileTxt,
|
||||
},
|
||||
{
|
||||
"file", testFileTxt, testFileTxt, newFile,
|
||||
"failed to download " + testFileTxt,
|
||||
},
|
||||
{
|
||||
"manifest temp file", testFileTxt, tempPathFor(defaultManifestName), newFile,
|
||||
"failed to save manifest",
|
||||
},
|
||||
{
|
||||
"manifest", testFileTxt, defaultManifestName, newFile,
|
||||
"failed to save manifest",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -473,23 +571,61 @@ func TestFetchRefusesSymlinks(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
outside := t.TempDir()
|
||||
cwd := chdirTemp(t)
|
||||
dest := t.TempDir()
|
||||
link := filepath.Join(dest, tt.link)
|
||||
|
||||
chdirTemp(t)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(tt.link), 0o750))
|
||||
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), tt.link))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o750))
|
||||
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), link))
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL,
|
||||
}, afero.NewOsFs())
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts), "failed to download "+tt.entry+
|
||||
": symlink in path not allowed: "+tt.link)
|
||||
assert.Contains(t, testStderr(t, opts),
|
||||
tt.failure+": symlink in path not allowed: "+link)
|
||||
|
||||
written, err := os.ReadDir(outside)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, written, "fetch wrote outside the destination")
|
||||
|
||||
written, err = os.ReadDir(cwd)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, written, "fetch wrote to the current directory")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDoesNotSkipThroughSymlink runs fetch with --dest holding a
|
||||
// symlink to a directory outside it, where the file the manifest lists
|
||||
// through that symlink already sits with the listed content. fetch must
|
||||
// not take that file as already present: it must fail on the symlink, as
|
||||
// the download would, and leave the outside file alone.
|
||||
func TestFetchDoesNotSkipThroughSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := []byte("fetched")
|
||||
files := map[string][]byte{"sub/" + testFileTxt: content}
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
|
||||
defer server.Close()
|
||||
|
||||
outside := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(outside, testFileTxt), content, 0o600))
|
||||
|
||||
dest := t.TempDir()
|
||||
link := filepath.Join(dest, "sub")
|
||||
require.NoError(t, os.Symlink(outside, link))
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL,
|
||||
}, afero.NewOsFs())
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts),
|
||||
"failed to download sub/"+testFileTxt+": symlink in path not allowed: "+link)
|
||||
assert.Equal(t, map[string][]byte{testFileTxt: content}, filesUnder(t, outside))
|
||||
}
|
||||
|
||||
// TestFetchReplacesHardLinkAtTempName runs fetch into a destination
|
||||
// directory that holds, at the temp file's name, a hard link to a file
|
||||
// outside it. To fetch that is an ordinary leftover from an interrupted
|
||||
@@ -523,3 +659,699 @@ func TestFetchReplacesHardLinkAtTempName(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "outside", string(outside), "fetch wrote outside the destination")
|
||||
}
|
||||
|
||||
// TestGetRetriesTransientStatusesOnly answers every request with one
|
||||
// status and counts the requests: a 5xx or 429 is retried until
|
||||
// fetchAttempts runs out, and any other status fails at once.
|
||||
func TestGetRetriesTransientStatusesOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
status int
|
||||
requests int32
|
||||
}{
|
||||
{http.StatusNotFound, 1},
|
||||
{http.StatusForbidden, 1},
|
||||
{http.StatusInternalServerError, fetchAttempts},
|
||||
{http.StatusServiceUnavailable, fetchAttempts},
|
||||
{http.StatusTooManyRequests, fetchAttempts},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(http.StatusText(tt.status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(tt.status)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := getNothing(testClient(), server.URL, 10*time.Second)
|
||||
require.ErrorIs(t, err, errHTTPStatus)
|
||||
require.EqualError(t, err, fmt.Sprintf("HTTP %d", tt.status))
|
||||
assert.Equal(t, tt.requests, requests.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTimesOutOnStalledServer points get at a server that takes a
|
||||
// request and never answers it. The try must give up at the client's
|
||||
// timeout and be retried; when every try stalls, get must return a
|
||||
// timeout error rather than hang.
|
||||
func TestGetTimesOutOnStalledServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stallEvery bool
|
||||
}{
|
||||
{"first request stalls", false},
|
||||
{"every request stalls", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
stop := make(chan struct{})
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
if requests.Add(1) == 1 || tt.stallEvery {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-stop:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
defer close(stop)
|
||||
|
||||
client := testClient()
|
||||
client.client.Timeout = 50 * time.Millisecond
|
||||
|
||||
err := getNothing(client, server.URL, 10*time.Second)
|
||||
if !tt.stallEvery {
|
||||
require.NoError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
require.ErrorAs(t, err, &netErr)
|
||||
assert.True(t, netErr.Timeout(), "want a timeout, got %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetHonorsRetryAfter answers the first request with a 503 and a
|
||||
// Retry-After header, and any later one with 200 OK. The client's own
|
||||
// wait before a retry is an hour, so get finishes in time only if it
|
||||
// waits as long as Retry-After says instead. A Retry-After longer than
|
||||
// maxRetryAfter must fail at once.
|
||||
func TestGetHonorsRetryAfter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
requests int32
|
||||
wantErr bool
|
||||
}{
|
||||
{"seconds", "0", 2, false},
|
||||
{"HTTP date", time.Now().Add(-time.Minute).UTC().Format(http.TimeFormat), 2, false},
|
||||
{"too long", strconv.Itoa(int((maxRetryAfter + time.Second).Seconds())), 1, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if requests.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", tt.value)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := testClient()
|
||||
client.firstDelay = time.Hour
|
||||
|
||||
err := getNothing(client, server.URL, 10*time.Second)
|
||||
if tt.wantErr {
|
||||
require.ErrorIs(t, err, errHTTPStatus)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.requests, requests.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetStopsWaitingWhenCanceled gives get a server that always answers
|
||||
// 503 and an hour's wait before each retry, then lets the context expire
|
||||
// during that wait. get returning at all shows it stopped waiting.
|
||||
func TestGetStopsWaitingWhenCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := testClient()
|
||||
client.firstDelay = time.Hour
|
||||
|
||||
err := getNothing(client, server.URL, 50*time.Millisecond)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// TestDownloadFileRetriesToSuccess serves a file that fails twice before
|
||||
// it succeeds: first with a 503, then with a body cut off halfway. The
|
||||
// download must end with the whole, verified file and no temp file.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
||||
content := []byte("the whole file, every byte of it")
|
||||
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(
|
||||
manifestOf(t, map[string][]byte{testFileTxt: content})))
|
||||
require.NoError(t, err)
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
switch requests.Add(1) {
|
||||
case 1:
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
case 2:
|
||||
// Promise the whole file but send half of it; the server
|
||||
// then closes the connection.
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
||||
_, _ = w.Write(content[:len(content)/2])
|
||||
default:
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(3), requests.Load())
|
||||
|
||||
fetched, err := os.ReadFile(testFileTxt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, fetched)
|
||||
|
||||
_, err = os.Stat(".file.txt.tmp")
|
||||
assert.True(t, os.IsNotExist(err), "temp file left behind")
|
||||
}
|
||||
|
||||
// TestFetchBaseURLForms fetches one tree by its directory URL with and
|
||||
// without a trailing slash, with a query string, and by its manifest URL.
|
||||
// Each must request the same manifest and file paths.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchBaseURLForms(t *testing.T) {
|
||||
files := map[string][]byte{"one.txt": []byte("1"), "sub/two.txt": []byte("2")}
|
||||
tree := http.StripPrefix("/tree", fetchTestHandler(manifestOf(t, files), files))
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requested []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
requested = append(requested, r.URL.Path)
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
tree.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
inputs := []string{"/tree", "/tree/", "/tree?key=value", "/tree/index.mf"}
|
||||
for _, input := range inputs {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
mu.Lock()
|
||||
requested = nil
|
||||
mu.Unlock()
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL + input},
|
||||
afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"/tree/index.mf", "/tree/one.txt", "/tree/sub/two.txt"}, requested)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchEscapedPaths fetches files whose names need escaping in a URL,
|
||||
// one of them a name that is itself an escape sequence. Each must arrive
|
||||
// under its own name with its own content.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchEscapedPaths(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"a b.txt": []byte("space"),
|
||||
"a%20b.txt": []byte("percent sign, two, zero"),
|
||||
"100%.txt": []byte("percent sign"),
|
||||
"q?x=1#frag.txt": []byte("question mark and hash"),
|
||||
"dir with space/sub.txt": []byte("directory with a space"),
|
||||
}
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
|
||||
defer server.Close()
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
for name, content := range files {
|
||||
fetched, err := os.ReadFile(name) //nolint:gosec // test-controlled path
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, fetched, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchTree runs fetch on a tree with nested directories. Every file
|
||||
// the manifest lists must land under its own path with its own content,
|
||||
// beside the manifest, and nothing else may be left in the destination.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchTree(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"top.txt": []byte("at the top"),
|
||||
"sub/one.txt": []byte("one level down"),
|
||||
"sub/deeper/two.txt": []byte("two levels down"),
|
||||
"other/deep/est.txt": []byte("in a second directory"),
|
||||
}
|
||||
|
||||
manifest := manifestOf(t, files)
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifest, files))
|
||||
defer server.Close()
|
||||
|
||||
dest := chdirTemp(t)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
want := maps.Clone(files)
|
||||
want[defaultManifestName] = manifest
|
||||
assert.Equal(t, want, filesUnder(t, dest))
|
||||
}
|
||||
|
||||
// TestFetchFailsOnHashMismatch runs fetch against a server that serves a
|
||||
// file with the size the manifest lists but different content. fetch must
|
||||
// exit non-zero and leave no file in the destination.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchFailsOnHashMismatch(t *testing.T) {
|
||||
listed := map[string][]byte{testDirFile: []byte("original")}
|
||||
served := map[string][]byte{testDirFile: []byte("tampered")}
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, listed), served))
|
||||
defer server.Close()
|
||||
|
||||
dest := chdirTemp(t)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Empty(t, filesUnder(t, dest))
|
||||
}
|
||||
|
||||
// TestFetchIntoPartlyFilledDestination runs fetch where an interrupted
|
||||
// fetch of an older version of the tree left one file current, one file
|
||||
// out of date and one half written to its temp file, beside a file the
|
||||
// manifest does not list. fetch skips the current file and downloads the
|
||||
// other two. The out-of-date file has the same size as the new version,
|
||||
// so only its hash shows that it must be replaced. The file the manifest
|
||||
// does not list is left alone, and the manifest is saved beside the files.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchIntoPartlyFilledDestination(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"current.txt": []byte("already fetched"),
|
||||
"sub/changed.txt": []byte("new version"),
|
||||
"sub/partial.txt": []byte("cut off partway"),
|
||||
}
|
||||
unlisted := []byte("not in the manifest")
|
||||
|
||||
manifest := manifestOf(t, files)
|
||||
tree := fetchTestHandler(manifest, files)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requested []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
requested = append(requested, r.URL.Path)
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
tree.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := chdirTemp(t)
|
||||
|
||||
require.NoError(t, os.MkdirAll("sub", 0o750))
|
||||
require.NoError(t, os.WriteFile("current.txt", files["current.txt"], 0o600))
|
||||
require.NoError(t, os.WriteFile("sub/changed.txt", []byte("old version"), 0o600))
|
||||
require.NoError(t,
|
||||
os.WriteFile(tempPathFor("sub/partial.txt"), []byte("cut off"), 0o600))
|
||||
require.NoError(t, os.WriteFile("unlisted.txt", unlisted, 0o600))
|
||||
|
||||
opts := testOpts([]string{testApp, cmdFetch, server.URL}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
assert.Contains(t, testStderr(t, opts), "skipping current.txt: already present")
|
||||
|
||||
want := maps.Clone(files)
|
||||
want["unlisted.txt"] = unlisted
|
||||
want[defaultManifestName] = manifest
|
||||
assert.Equal(t, want, filesUnder(t, dest))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.ElementsMatch(t, []string{
|
||||
"/" + defaultManifestName, "/sub/changed.txt", "/sub/partial.txt",
|
||||
}, requested)
|
||||
}
|
||||
|
||||
// TestFetchIntoDest fetches a tree with --dest into a directory that does
|
||||
// not exist yet. The files and the manifest must land there and nowhere
|
||||
// else, and check must pass on the result with no extra files. A second
|
||||
// fetch into the same directory must download nothing but the manifest.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchIntoDest(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"top.txt": []byte("at the top"),
|
||||
"sub/one.txt": []byte("one level down"),
|
||||
}
|
||||
|
||||
manifest := manifestOf(t, files)
|
||||
tree := fetchTestHandler(manifest, files)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requested []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
requested = append(requested, r.URL.Path)
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
tree.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cwd := chdirTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "mirror")
|
||||
fetch := []string{testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL}
|
||||
|
||||
opts := testOpts(fetch, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
want := maps.Clone(files)
|
||||
want[defaultManifestName] = manifest
|
||||
assert.Equal(t, want, filesUnder(t, dest))
|
||||
assert.Empty(t, filesUnder(t, cwd), "fetch wrote outside --dest")
|
||||
|
||||
check := testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, dest, testFlagNoExtra,
|
||||
filepath.Join(dest, defaultManifestName),
|
||||
}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(check), testStderr(t, check))
|
||||
|
||||
mu.Lock()
|
||||
requested = nil
|
||||
mu.Unlock()
|
||||
|
||||
opts = testOpts(fetch, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
assert.Equal(t, want, filesUnder(t, dest))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(t, []string{"/" + defaultManifestName}, requested)
|
||||
}
|
||||
|
||||
// TestFetchRequireSignature runs fetch with --require-signature. A
|
||||
// manifest that is unsigned, or signed by another key, must stop fetch
|
||||
// with check's message before it downloads or writes anything; the
|
||||
// required key lets it through. The signed cases need gpg and are skipped
|
||||
// without it, as the other signing tests are.
|
||||
//
|
||||
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
|
||||
func TestFetchRequireSignature(t *testing.T) {
|
||||
files := map[string][]byte{testFileTxt: []byte("signed file")}
|
||||
|
||||
t.Run("unsigned", func(t *testing.T) {
|
||||
assertFetchRefused(t, manifestOf(t, files), files,
|
||||
"manifest is not signed, but signature from "+msgFpA+" is required",
|
||||
"--"+flagRequireSignature, msgFpA)
|
||||
})
|
||||
|
||||
t.Run("signed", func(t *testing.T) {
|
||||
manifest := signedManifest(t, files)
|
||||
|
||||
signer, err := signedChecker(t, manifest).
|
||||
ExtractEmbeddedSigningKeyFP(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertFetchRefused(t, manifest, files,
|
||||
"embedded signing key fingerprint "+signer+" does not match required "+msgFpB,
|
||||
"--"+flagRequireSignature, msgFpB)
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifest, files))
|
||||
defer server.Close()
|
||||
|
||||
dest := t.TempDir()
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFetch, "-q", "--" + flagDest, dest,
|
||||
"--" + flagRequireSignature, signer, server.URL,
|
||||
}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
assert.Equal(t, files[testFileTxt], filesUnder(t, dest)[testFileTxt])
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetchRefusesListedManifestName fetches manifests that list, at the
|
||||
// top of the tree, the name fetch saves the manifest under or that name's
|
||||
// temp file: as a file, as a directory, in capitals, and with a leading
|
||||
// "./". Saving the manifest would replace or remove what is listed there,
|
||||
// so fetch must refuse the manifest before it creates the destination or
|
||||
// requests any file.
|
||||
func TestFetchRefusesListedManifestName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, listed := range []string{
|
||||
defaultManifestName,
|
||||
tempPathFor(defaultManifestName),
|
||||
defaultManifestName + "/" + testFileTxt,
|
||||
"INDEX.MF",
|
||||
"./" + defaultManifestName,
|
||||
} {
|
||||
t.Run(listed, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := map[string][]byte{listed: []byte("listed")}
|
||||
|
||||
assertFetchRefused(t, builtManifest(t, files), files,
|
||||
"manifest lists a file where fetch writes another file: "+listed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchRefusesListedTempName fetches manifests that list a.txt and
|
||||
// .a.txt.tmp, the temp file fetch downloads a.txt to, at the top of the
|
||||
// tree and in a directory. Downloading a.txt would remove .a.txt.tmp, so
|
||||
// fetch must refuse the manifest before it creates the destination or
|
||||
// requests any file. README with .README.tmp checks that temp names are
|
||||
// compared ignoring case. A manifest that lists only one of the two is
|
||||
// fetched in full.
|
||||
func TestFetchRefusesListedTempName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, dir := range []string{"", "sub/"} {
|
||||
for file, tmp := range map[string]string{
|
||||
dir + "a.txt": dir + ".a.txt.tmp",
|
||||
dir + "README": dir + ".README.tmp",
|
||||
} {
|
||||
both := map[string][]byte{
|
||||
file: []byte("a file"),
|
||||
tmp: []byte("a file at its temp name"),
|
||||
}
|
||||
|
||||
t.Run(file+" and "+tmp, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertFetchRefused(t, builtManifest(t, both), both,
|
||||
"manifest lists a file where fetch writes another file: "+
|
||||
tmp+" (the temp file for "+file+")")
|
||||
})
|
||||
|
||||
for listed, content := range both {
|
||||
t.Run("only "+listed, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := map[string][]byte{listed: content}
|
||||
manifest := builtManifest(t, files)
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifest, files))
|
||||
defer server.Close()
|
||||
|
||||
dest := t.TempDir()
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL,
|
||||
}, afero.NewOsFs())
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
want := maps.Clone(files)
|
||||
want[defaultManifestName] = manifest
|
||||
assert.Equal(t, want, filesUnder(t, dest))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// builtManifest returns a manifest of files, built directly rather than
|
||||
// scanned, since a scan lists no hidden files and never a path starting
|
||||
// with "./".
|
||||
func builtManifest(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
builder := mfer.NewBuilder()
|
||||
|
||||
for p, content := range files {
|
||||
_, err := builder.AddFile(mfer.RelFilePath(p), mfer.FileSize(len(content)),
|
||||
mfer.ModTime(time.Now()), bytes.NewReader(content), nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
var manifest bytes.Buffer
|
||||
|
||||
require.NoError(t, builder.Build(context.Background(), &manifest))
|
||||
|
||||
return manifest.Bytes()
|
||||
}
|
||||
|
||||
// assertFetchRefused serves manifest, a manifest of files, and fetches it
|
||||
// with flags into a directory that does not exist yet. fetch must fail
|
||||
// with message after requesting only the manifest, and must not create
|
||||
// the directory.
|
||||
func assertFetchRefused(
|
||||
t *testing.T, manifest []byte, files map[string][]byte,
|
||||
message string, flags ...string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
tree := fetchTestHandler(manifest, files)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requested []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
requested = append(requested, r.URL.Path)
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
tree.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "mirror")
|
||||
|
||||
opts := testOpts(slices.Concat(
|
||||
[]string{testApp, cmdFetch, "-q", "--" + flagDest, dest}, flags, []string{server.URL},
|
||||
), afero.NewOsFs())
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts), message)
|
||||
assert.NoDirExists(t, dest, "fetch created the destination before refusing")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(t, []string{"/" + defaultManifestName}, requested)
|
||||
}
|
||||
|
||||
// TestFetchTimeoutFlag runs fetch with --timeout against a server that
|
||||
// never answers. Without the flag's limit the request would wait forever;
|
||||
// once fetch gives up on it, the server cancels fetch's context so that
|
||||
// fetch returns instead of retrying.
|
||||
func TestFetchTimeoutFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done() // fetch gave up on this request
|
||||
cancel()
|
||||
}))
|
||||
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(ctx, []string{cmdFetch, "--" + flagTimeout, "100ms", server.URL})
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestFetchRejectsTimeoutOfZeroOrLess runs fetch with a --timeout of zero
|
||||
// and of less than zero. http.Client takes either as no time limit at all,
|
||||
// so fetch must refuse it before making any request.
|
||||
func TestFetchRejectsTimeoutOfZeroOrLess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
requests.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, timeout := range []string{"0", "-1s"} {
|
||||
opts := testOpts(
|
||||
[]string{testApp, cmdFetch, "--" + flagTimeout + "=" + timeout, server.URL},
|
||||
afero.NewMemMapFs())
|
||||
|
||||
assert.Equal(t, 1, runCLI(opts), timeout)
|
||||
assert.Contains(t, testStderr(t, opts), errInvalidTimeout.Error(), timeout)
|
||||
}
|
||||
|
||||
assert.Zero(t, requests.Load(), "fetch made a request")
|
||||
}
|
||||
|
||||
+60
-31
@@ -1,18 +1,20 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
@@ -55,7 +57,7 @@ type freshenEntry struct {
|
||||
type freshenScanner struct {
|
||||
fs afero.Fs
|
||||
absBase string
|
||||
manifestBase string
|
||||
excluded []fs.FileInfo // files left out of the listing
|
||||
includeDotfiles bool
|
||||
followSymlinks bool
|
||||
showProgress bool
|
||||
@@ -155,11 +157,6 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
|
||||
"freshen: failed to compute relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Skip the manifest file itself
|
||||
if relPath == s.manifestBase || relPath == "."+s.manifestBase {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle dotfiles
|
||||
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
||||
if info.IsDir() {
|
||||
@@ -184,6 +181,12 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
|
||||
info = realInfo
|
||||
}
|
||||
|
||||
for _, excluded := range s.excluded {
|
||||
if os.SameFile(info, excluded) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
s.scanCount++
|
||||
|
||||
// Check against existing manifest
|
||||
@@ -199,12 +202,12 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
|
||||
|
||||
// resolveFreshenManifestPath determines the manifest path from the CLI
|
||||
// arguments, searching directories for a manifest where needed.
|
||||
func (mfa *CLIApp) resolveFreshenManifestPath(ctx *cli.Context) (string, error) {
|
||||
if ctx.Args().Len() == 0 {
|
||||
func (mfa *CLIApp) resolveFreshenManifestPath(cmd *cli.Command) (string, error) {
|
||||
if cmd.Args().Len() == 0 {
|
||||
return findManifest(mfa.Fs, ".")
|
||||
}
|
||||
|
||||
arg := ctx.Args().Get(0)
|
||||
arg := cmd.Args().Get(0)
|
||||
|
||||
info, statErr := mfa.Fs.Stat(arg)
|
||||
if statErr == nil && info.IsDir() {
|
||||
@@ -304,16 +307,16 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error {
|
||||
// writeFreshenedManifest writes the manifest atomically (write to a
|
||||
// temp file, then rename over the target).
|
||||
func writeFreshenedManifest(
|
||||
afs afero.Fs, builder *mfer.Builder, manifestPath string,
|
||||
ctx context.Context, afs afero.Fs, builder *mfer.Builder, manifestPath string,
|
||||
) error {
|
||||
tmpPath := manifestPath + ".tmp"
|
||||
tmpPath := manifestTempPath(manifestPath)
|
||||
|
||||
outFile, err := afs.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
err = builder.Build(outFile)
|
||||
err = builder.Build(ctx, outFile)
|
||||
_ = outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
@@ -335,14 +338,14 @@ func writeFreshenedManifest(
|
||||
|
||||
// newFreshenBuilder constructs the manifest builder configured from CLI
|
||||
// flags.
|
||||
func newFreshenBuilder(ctx *cli.Context) *mfer.Builder {
|
||||
func newFreshenBuilder(cmd *cli.Command) *mfer.Builder {
|
||||
builder := mfer.NewBuilder()
|
||||
if ctx.Bool("include-timestamps") {
|
||||
if cmd.Bool("include-timestamps") {
|
||||
builder.SetIncludeTimestamps(true)
|
||||
}
|
||||
|
||||
// Set up signing options if sign-key is provided
|
||||
if signKey := ctx.String("sign-key"); signKey != "" {
|
||||
if signKey := cmd.String("sign-key"); signKey != "" {
|
||||
builder.SetSigningOptions(&mfer.SigningOptions{
|
||||
KeyID: mfer.GPGKeyID(signKey),
|
||||
})
|
||||
@@ -355,20 +358,32 @@ func newFreshenBuilder(ctx *cli.Context) *mfer.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(
|
||||
ctx *cli.Context, manifestPath, absBase string,
|
||||
cmd *cli.Command, manifestPath, absBase string,
|
||||
existingByPath map[string]*mfer.MFFilePath,
|
||||
) (*freshenScanner, int64, error) {
|
||||
log.Infof("scanning filesystem...")
|
||||
|
||||
startScan := time.Now()
|
||||
showProgress := ctx.Bool("progress")
|
||||
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,
|
||||
manifestBase: filepath.Base(manifestPath),
|
||||
includeDotfiles: ctx.Bool("include-dotfiles"),
|
||||
followSymlinks: ctx.Bool("follow-symlinks"),
|
||||
excluded: excluded,
|
||||
includeDotfiles: cmd.Bool("include-dotfiles"),
|
||||
followSymlinks: cmd.Bool("follow-symlinks"),
|
||||
showProgress: showProgress,
|
||||
existingByPath: existingByPath,
|
||||
}
|
||||
@@ -418,7 +433,7 @@ func hashTotals(entries []*freshenEntry) (int64, int64) {
|
||||
// runFreshenHash processes every entry through the hasher, aborting if
|
||||
// the context is canceled.
|
||||
func runFreshenHash(
|
||||
ctx *cli.Context, hasher *freshenHasher, entries []*freshenEntry,
|
||||
ctx context.Context, hasher *freshenHasher, entries []*freshenEntry,
|
||||
) error {
|
||||
for _, e := range entries {
|
||||
select {
|
||||
@@ -444,7 +459,10 @@ func (mfa *CLIApp) loadExistingEntries(
|
||||
log.Infof("loading manifest from %s", manifestPath)
|
||||
|
||||
// Load existing manifest
|
||||
manifest, err := mfer.NewManifestFromFile(mfa.Fs, manifestPath)
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: manifestPath,
|
||||
Fs: mfa.Fs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load manifest: %w", err)
|
||||
}
|
||||
@@ -461,18 +479,21 @@ func (mfa *CLIApp) loadExistingEntries(
|
||||
return existingByPath, nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
func (mfa *CLIApp) freshenManifestOperation(
|
||||
ctx context.Context, cmd *cli.Command,
|
||||
) error {
|
||||
log.Debug("freshenManifestOperation()")
|
||||
|
||||
basePath := ctx.String("base")
|
||||
showProgress := ctx.Bool("progress")
|
||||
basePath := cmd.String("base")
|
||||
showProgress := cmd.Bool("progress")
|
||||
|
||||
// Find manifest file
|
||||
manifestPath, err := mfa.resolveFreshenManifestPath(ctx)
|
||||
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
|
||||
@@ -484,7 +505,7 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Phase 1: Scan filesystem
|
||||
scanner, removed, err := mfa.freshenScan(ctx, manifestPath, absBase,
|
||||
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
||||
existingByPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -506,7 +527,7 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
totalHashBytes: totalHashBytes,
|
||||
filesToHash: filesToHash,
|
||||
startHash: time.Now(),
|
||||
builder: newFreshenBuilder(ctx),
|
||||
builder: newFreshenBuilder(cmd),
|
||||
}
|
||||
|
||||
err = runFreshenHash(ctx, hasher, scanner.entries)
|
||||
@@ -530,7 +551,7 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Write updated manifest atomically (write to temp, then rename)
|
||||
err = writeFreshenedManifest(mfa.Fs, hasher.builder, manifestPath)
|
||||
err = writeFreshenedManifest(ctx, mfa.Fs, hasher.builder, manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -611,7 +632,15 @@ func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
||||
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
||||
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
|
||||
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
|
||||
}
|
||||
|
||||
+244
-46
@@ -2,15 +2,19 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
@@ -28,71 +32,236 @@ func (s stubFileInfo) ModTime() time.Time { return s.mtime }
|
||||
func (s stubFileInfo) IsDir() bool { return false }
|
||||
func (s stubFileInfo) Sys() any { return nil }
|
||||
|
||||
// setupFreshenDir populates /testdir with two files, scans it, and
|
||||
// writes the resulting manifest to /testdir/.index.mf.
|
||||
func setupFreshenDir(t *testing.T, fs afero.Fs) {
|
||||
// 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()
|
||||
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "content1")
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "content2")
|
||||
root := t.TempDir()
|
||||
manifestPath := filepath.Join(root, defaultManifestName)
|
||||
|
||||
// Generate initial manifest
|
||||
opts := &mfer.ScannerOptions{Fs: fs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumeratePath(testDir, nil))
|
||||
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)
|
||||
}
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Write manifest to filesystem
|
||||
require.NoError(t,
|
||||
afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
setupFreshenDir(t, fs)
|
||||
fs := afero.NewOsFs()
|
||||
root, manifestPath := setupFreshenDir(t, fs,
|
||||
map[string]string{testFileTxt: "content1", testDirFile: "content2"})
|
||||
before := manifestFiles(t, fs, manifestPath)
|
||||
|
||||
// Parse manifest to verify
|
||||
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
runFreshen(t, fs, root, manifestPath)
|
||||
|
||||
after := manifestFiles(t, fs, manifestPath)
|
||||
require.Len(t, after, len(before))
|
||||
|
||||
for i := range before {
|
||||
assert.True(t, proto.Equal(before[i], after[i]),
|
||||
"entry for %s changed", before[i].GetPath())
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
setupFreshenDir(t, fs)
|
||||
tree := map[string]string{testFileTxt: "content1", testDirFile: "content2"}
|
||||
|
||||
// Verify initial manifest has 2 files
|
||||
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
// Every file a case writes gets this mtime, which differs from the one
|
||||
// gen recorded, so an edit that keeps the size is told apart by its
|
||||
// mtime whatever the filesystem's clock resolution.
|
||||
writtenMtime := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// Add a new file
|
||||
writeTestFile(t, fs, "/testdir/file3.txt", "content3")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
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()
|
||||
|
||||
// Modify file2 (change content and size)
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "modified content2")
|
||||
fs := afero.NewOsFs()
|
||||
root, manifestPath := setupFreshenDir(t, fs, tree)
|
||||
|
||||
// Remove file1
|
||||
require.NoError(t, fs.Remove(testFile1))
|
||||
// want is the tree as it is after the change.
|
||||
want := maps.Clone(tree)
|
||||
|
||||
// Note: The freshen operation would need to be run here
|
||||
// For now, we just verify the test setup is correct
|
||||
exists, _ := afero.Exists(fs, testFile1)
|
||||
assert.False(t, exists)
|
||||
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
|
||||
}
|
||||
|
||||
exists, _ = afero.Exists(fs, "/testdir/file3.txt")
|
||||
assert.True(t, exists)
|
||||
if tc.remove != "" {
|
||||
require.NoError(t, fs.Remove(filepath.Join(root, tc.remove)))
|
||||
delete(want, tc.remove)
|
||||
}
|
||||
|
||||
content, _ := afero.ReadFile(fs, "/testdir/file2.txt")
|
||||
assert.Equal(t, "modified content2", string(content))
|
||||
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
|
||||
@@ -169,21 +338,50 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
||||
func TestFreshenAddExistingRejectsMissingMtime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
b := mfer.NewBuilder()
|
||||
entry := &mfer.MFFilePath{
|
||||
Path: "file1.txt",
|
||||
Size: 8,
|
||||
Mtime: nil,
|
||||
Hashes: []*mfer.MFFileChecksum{
|
||||
{MultiHash: []byte{0x12, 0x20}},
|
||||
{MultiHash: hash},
|
||||
},
|
||||
}
|
||||
|
||||
err := addExistingToBuilder(b, entry)
|
||||
err = addExistingToBuilder(b, entry)
|
||||
require.ErrorIs(t, err, errEntryMissingMtime)
|
||||
assert.Contains(t, err.Error(), "file1.txt")
|
||||
}
|
||||
|
||||
// TestFreshenAddExistingRejectsShortHash pins that an existing manifest
|
||||
// entry whose hash the builder refuses is reported as a problem with the
|
||||
// manifest, with the command that fixes it.
|
||||
func TestFreshenAddExistingRejectsShortHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
||||
require.NoError(t, err)
|
||||
|
||||
b := mfer.NewBuilder()
|
||||
entry := &mfer.MFFilePath{
|
||||
Path: "old.txt",
|
||||
Size: 8,
|
||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||
Hashes: []*mfer.MFFileChecksum{
|
||||
{MultiHash: sha1Hash},
|
||||
},
|
||||
}
|
||||
|
||||
err = addExistingToBuilder(b, entry)
|
||||
require.Error(t, err)
|
||||
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()
|
||||
|
||||
+25
-18
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
@@ -88,23 +89,27 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
||||
}
|
||||
|
||||
// buildScannerOptions constructs scanner options from the CLI flags.
|
||||
func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions {
|
||||
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
|
||||
output := cmd.String("output")
|
||||
opts := &mfer.ScannerOptions{
|
||||
IncludeDotfiles: ctx.Bool("include-dotfiles"),
|
||||
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
||||
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
||||
IncludeDotfiles: cmd.Bool("include-dotfiles"),
|
||||
FollowSymLinks: cmd.Bool("follow-symlinks"),
|
||||
IncludeTimestamps: cmd.Bool("include-timestamps"),
|
||||
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
|
||||
if seed := ctx.String("seed"); seed != "" {
|
||||
if seed := cmd.String("seed"); seed != "" {
|
||||
opts.Seed = seed
|
||||
|
||||
log.Infof("using deterministic seed for manifest UUID")
|
||||
}
|
||||
|
||||
// Set up signing options if sign-key is provided
|
||||
if signKey := ctx.String("sign-key"); signKey != "" {
|
||||
if signKey := cmd.String("sign-key"); signKey != "" {
|
||||
opts.SigningOptions = &mfer.SigningOptions{
|
||||
KeyID: mfer.GPGKeyID(signKey),
|
||||
}
|
||||
@@ -169,14 +174,14 @@ func (mfa *CLIApp) cleanupOnSignal(outFile afero.File, tmpPath string) chan os.S
|
||||
|
||||
// runEnumeratePhase enumerates all input paths with optional progress
|
||||
// reporting and logs the totals.
|
||||
func (mfa *CLIApp) runEnumeratePhase(ctx *cli.Context, s *mfer.Scanner) error {
|
||||
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 ctx.Bool("progress") {
|
||||
if cmd.Bool("progress") {
|
||||
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
||||
|
||||
enumWg.Add(1)
|
||||
@@ -184,7 +189,7 @@ func (mfa *CLIApp) runEnumeratePhase(ctx *cli.Context, s *mfer.Scanner) error {
|
||||
go reportEnumProgress(enumProgress, &enumWg)
|
||||
}
|
||||
|
||||
err := mfa.enumerateInputs(s, ctx.Args(), enumProgress)
|
||||
err := mfa.enumerateInputs(s, cmd.Args(), enumProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,27 +202,29 @@ func (mfa *CLIApp) runEnumeratePhase(ctx *cli.Context, s *mfer.Scanner) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
func (mfa *CLIApp) generateManifestOperation(
|
||||
ctx context.Context, cmd *cli.Command,
|
||||
) error {
|
||||
log.Debug("generateManifestOperation()")
|
||||
|
||||
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(ctx))
|
||||
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
|
||||
|
||||
// Phase 1: Enumeration - collect paths and stat files
|
||||
err := mfa.runEnumeratePhase(ctx, s)
|
||||
err := mfa.runEnumeratePhase(cmd, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
showProgress := ctx.Bool("progress")
|
||||
showProgress := cmd.Bool("progress")
|
||||
|
||||
// Check if output file exists
|
||||
outputPath := ctx.String("output")
|
||||
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !ctx.Bool("force") {
|
||||
outputPath := cmd.String("output")
|
||||
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
|
||||
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
||||
}
|
||||
|
||||
// Create temp file for atomic write
|
||||
tmpPath := outputPath + ".tmp"
|
||||
tmpPath := manifestTempPath(outputPath)
|
||||
|
||||
outFile, err := mfa.Fs.Create(tmpPath)
|
||||
if err != nil {
|
||||
@@ -255,7 +262,7 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
go reportScanProgress(scanProgress, &scanWg)
|
||||
}
|
||||
|
||||
err = s.ToManifest(ctx.Context, outFile, scanProgress)
|
||||
err = s.ToManifest(ctx, outFile, scanProgress)
|
||||
|
||||
scanWg.Wait()
|
||||
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
func (mfa *CLIApp) listManifestOperation(ctx *cli.Context) error {
|
||||
func (mfa *CLIApp) listManifestOperation(ctx context.Context, cmd *cli.Command) error {
|
||||
// Default to ErrorLevel for clean output
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
|
||||
longFormat := ctx.Bool("long")
|
||||
print0 := ctx.Bool("print0")
|
||||
longFormat := cmd.Bool("long")
|
||||
print0 := cmd.Bool("print0")
|
||||
|
||||
pathOrURL, err := mfa.resolveManifestArg(ctx)
|
||||
pathOrURL, err := mfa.resolveManifestArg(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list: %w", err)
|
||||
}
|
||||
|
||||
rc, err := mfa.openManifestReader(pathOrURL)
|
||||
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
manifest, err := mfer.NewManifestFromReader(rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list: failed to parse manifest: %w", err)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// manifestFetchTimeout bounds HTTP requests made to fetch a manifest.
|
||||
@@ -30,13 +30,13 @@ func isHTTPURL(s string) bool {
|
||||
|
||||
// openManifestReader opens a manifest from a path or URL and returns a ReadCloser.
|
||||
// The caller must close the returned reader.
|
||||
func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
|
||||
func (mfa *CLIApp) openManifestReader(
|
||||
ctx context.Context, pathOrURL string,
|
||||
) (io.ReadCloser, error) {
|
||||
if isHTTPURL(pathOrURL) {
|
||||
client := &http.Client{Timeout: manifestFetchTimeout}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, pathOrURL, nil,
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pathOrURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
||||
}
|
||||
@@ -65,11 +65,11 @@ func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
|
||||
}
|
||||
|
||||
// resolveManifestArg resolves the manifest path from CLI arguments.
|
||||
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf/.index.mf.
|
||||
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf.
|
||||
// If no argument is given, the current directory is searched.
|
||||
func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
|
||||
if ctx.Args().Len() > 0 {
|
||||
arg := ctx.Args().Get(0)
|
||||
func (mfa *CLIApp) resolveManifestArg(cmd *cli.Command) (string, error) {
|
||||
if cmd.Args().Len() > 0 {
|
||||
arg := cmd.Args().Get(0)
|
||||
if isHTTPURL(arg) {
|
||||
return arg, nil
|
||||
}
|
||||
|
||||
+168
-72
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -8,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
@@ -17,14 +18,30 @@ import (
|
||||
const (
|
||||
cmdGenerate = "generate"
|
||||
cmdCheck = "check"
|
||||
cmdFreshen = "freshen"
|
||||
cmdExport = "export"
|
||||
cmdFetch = "fetch"
|
||||
cmdVersion = "version"
|
||||
|
||||
flagProgress = "progress"
|
||||
flagProgress = "progress"
|
||||
flagTimeout = "timeout"
|
||||
flagDest = "dest"
|
||||
flagRequireSignature = "require-signature"
|
||||
|
||||
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")
|
||||
|
||||
@@ -38,7 +55,7 @@ type CLIApp struct {
|
||||
gitrev string
|
||||
startupTime time.Time
|
||||
exitCode int
|
||||
app *cli.App
|
||||
app *cli.Command
|
||||
|
||||
Stdin io.Reader // Standard input stream
|
||||
Stdout io.Writer // Standard output stream for normal output
|
||||
@@ -68,6 +85,12 @@ func (mfa *CLIApp) VersionString() string {
|
||||
return mfer.Version
|
||||
}
|
||||
|
||||
// printVersion writes the version line shared by the --version flag and the
|
||||
// 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)
|
||||
@@ -78,46 +101,84 @@ func (mfa *CLIApp) printBanner() {
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) setVerbosity(c *cli.Context) {
|
||||
// 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")
|
||||
|
||||
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()
|
||||
case c.Bool("quiet"):
|
||||
case quiet:
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
default:
|
||||
log.SetLevelFromVerbosity(c.Count("verbose"))
|
||||
log.SetLevelFromVerbosity(verbosity)
|
||||
}
|
||||
}
|
||||
|
||||
// commonFlags returns the flags shared by most commands (-v, -q)
|
||||
// commonFlags returns the -v and -q flags taken by the root and by the
|
||||
// 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 {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Increase verbosity (-v for verbose, -vv for debug)",
|
||||
Count: new(int),
|
||||
Usage: "Increase verbosity (-v for verbose, -v -v for debug)",
|
||||
Local: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "quiet",
|
||||
Aliases: []string{"q"},
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdGenerate,
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate manifest file",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
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(c)
|
||||
return mfa.generateManifestOperation(ctx, cmd)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.BoolFlag{
|
||||
@@ -133,7 +194,7 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Value: "./.index.mf",
|
||||
Value: defaultManifestName,
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Specify output filename",
|
||||
},
|
||||
@@ -151,12 +212,12 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
Name: "sign-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "GPG key ID to sign the manifest with",
|
||||
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "seed",
|
||||
Usage: "Seed value for deterministic manifest UUID",
|
||||
EnvVars: []string{"MFER_SEED"},
|
||||
Sources: cli.EnvVars("MFER_SEED"),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-timestamps",
|
||||
@@ -169,14 +230,15 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) checkCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdCheck,
|
||||
Usage: "Validate files using manifest file",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
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(c)
|
||||
return mfa.checkManifestOperation(ctx, cmd)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.StringFlag{
|
||||
@@ -192,28 +254,24 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
|
||||
},
|
||||
&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"},
|
||||
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: "freshen",
|
||||
Usage: "Update manifest with changed, new, and removed files",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
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(c)
|
||||
return mfa.freshenManifestOperation(ctx, cmd)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.StringFlag{
|
||||
@@ -242,7 +300,7 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
||||
Name: "sign-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "GPG key ID to sign the manifest with",
|
||||
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-timestamps",
|
||||
@@ -255,21 +313,25 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) exportCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdExport,
|
||||
Usage: "Export manifest contents as JSON",
|
||||
ArgsUsage: "[manifest file or URL]",
|
||||
Action: func(c *cli.Context) error {
|
||||
return mfa.exportManifestOperation(c)
|
||||
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: "version",
|
||||
Usage: "Show version",
|
||||
Action: func(_ *cli.Context) error {
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString())
|
||||
Name: cmdVersion,
|
||||
Usage: "Show version",
|
||||
StopOnNthArg: stopOnFirstArg(),
|
||||
Action: func(context.Context, *cli.Command) error {
|
||||
mfa.printVersion()
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -278,13 +340,12 @@ func (mfa *CLIApp) versionCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) listCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List files in manifest",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
Action: func(c *cli.Context) error {
|
||||
return mfa.listManifestOperation(c)
|
||||
},
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List files in manifest",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
StopOnNthArg: stopOnFirstArg(),
|
||||
Action: mfa.listManifestOperation,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "long",
|
||||
@@ -301,15 +362,31 @@ func (mfa *CLIApp) listCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdFetch,
|
||||
Usage: "fetch manifest and referenced files",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
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(c)
|
||||
return mfa.fetchManifestOperation(ctx, cmd)
|
||||
},
|
||||
Flags: commonFlags(),
|
||||
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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,21 +402,40 @@ func (mfa *CLIApp) run(args []string) {
|
||||
log.SetOutput(mfa.Stdout, mfa.Stderr)
|
||||
log.Init()
|
||||
|
||||
mfa.app = &cli.App{
|
||||
Name: mfa.appname,
|
||||
Usage: "Manifest generator",
|
||||
Version: mfa.VersionString(),
|
||||
EnableBashCompletion: true,
|
||||
Writer: mfa.Stdout,
|
||||
ErrWriter: mfa.Stderr,
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.Args().Len() > 0 {
|
||||
return fmt.Errorf("%w %q", errUnknownCommand, c.Args().First())
|
||||
// -v means verbose, not version, at the root as on the generate, check,
|
||||
// freshen and fetch subcommands: verbose is the more common meaning of -v
|
||||
// in tools that offer both. urfave/cli's built-in version flag claims -v
|
||||
// by default, so the version flag takes the capital -V instead.
|
||||
// VersionFlag and VersionPrinter are urfave/cli package globals; run() is
|
||||
// serialized in tests, so assigning them here is safe.
|
||||
cli.VersionFlag = &cli.BoolFlag{
|
||||
Name: cmdVersion,
|
||||
Aliases: []string{"V"},
|
||||
Usage: "print the version",
|
||||
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()
|
||||
|
||||
return cli.ShowAppHelp(c)
|
||||
return cli.ShowRootCommandHelp(cmd)
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
mfa.generateCommand(),
|
||||
@@ -354,7 +450,7 @@ func (mfa *CLIApp) run(args []string) {
|
||||
|
||||
mfa.app.HideVersion = false
|
||||
|
||||
err := mfa.app.Run(args)
|
||||
err := mfa.app.Run(context.Background(), args)
|
||||
if err != nil {
|
||||
mfa.exitCode = 1
|
||||
|
||||
|
||||
+149
-55
@@ -1,19 +1,25 @@
|
||||
// Package log provides leveled logging with progress output helpers
|
||||
// on top of apex/log and pterm.
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/apex/log"
|
||||
acli "github.com/apex/log/handlers/cli"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/pterm/pterm"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Level represents log severity levels.
|
||||
@@ -58,28 +64,98 @@ func (l Level) String() string {
|
||||
// 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 (
|
||||
// mu protects the output writers and level
|
||||
// mu protects the variables below
|
||||
mu sync.RWMutex
|
||||
// stdout is the writer for progress output
|
||||
stdout io.Writer = os.Stdout
|
||||
// stderr is the writer for log output
|
||||
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 = 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.
|
||||
// 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) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
stdout = out
|
||||
stderr = err
|
||||
|
||||
pterm.SetDefaultOutput(out)
|
||||
}
|
||||
|
||||
// GetStdout returns the configured stdout writer.
|
||||
@@ -98,31 +174,29 @@ func GetStderr() io.Writer {
|
||||
return stderr
|
||||
}
|
||||
|
||||
// DisableStyling turns off colors and styling for terminal output.
|
||||
// DisableStyling turns off colors in log lines from the next Init on.
|
||||
func DisableStyling() {
|
||||
pterm.DisableColor()
|
||||
pterm.DisableStyling()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
pterm.Debug.Prefix.Text = ""
|
||||
pterm.Info.Prefix.Text = ""
|
||||
pterm.Success.Prefix.Text = ""
|
||||
pterm.Warning.Prefix.Text = ""
|
||||
pterm.Error.Prefix.Text = ""
|
||||
pterm.Fatal.Prefix.Text = ""
|
||||
styled = false
|
||||
}
|
||||
|
||||
// Init initializes the logger with the CLI handler and default log level.
|
||||
// Init sends log records to the CLI handler on the stderr writer given to
|
||||
// SetOutput. Log lines are colored when stdout is a terminal whose TERM is
|
||||
// not dumb, unless DisableStyling has been called.
|
||||
//
|
||||
// It reconfigures the process-global apex/log logger under the write lock so
|
||||
// the global is never mutated while another goroutine holds the read lock to
|
||||
// read it in emit. Without this, parallel callers (e.g. the test suite) race
|
||||
// Init's SetLevel/SetHandler against concurrent log calls.
|
||||
// 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() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
log.SetHandler(acli.New(stderr))
|
||||
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
|
||||
color := styled && os.Getenv("TERM") != "dumb" &&
|
||||
term.IsTerminal(int(os.Stdout.Fd()))
|
||||
|
||||
logger = slog.New(&cliHandler{w: stderr, color: color})
|
||||
}
|
||||
|
||||
// isEnabled returns true if messages at the given level should be logged.
|
||||
@@ -133,66 +207,87 @@ func isEnabled(l Level) bool {
|
||||
return l >= currentLevel
|
||||
}
|
||||
|
||||
// emit calls fn while holding the read lock if messages at level l are
|
||||
// enabled. Holding the read lock across the apex/log call keeps the global
|
||||
// logger from being read while Init reconfigures it under the write lock.
|
||||
func emit(l Level, fn func()) {
|
||||
// logf logs a formatted message at level l if messages at l are enabled,
|
||||
// holding the read lock while the record is written. slog has no verbose or
|
||||
// fatal level: verbose messages are logged as info records, and fatal
|
||||
// messages as error records.
|
||||
func logf(l Level, format string, args ...any) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
|
||||
if l >= currentLevel {
|
||||
fn()
|
||||
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.
|
||||
// Fatalf logs a formatted message at fatal level, then exits with status 1.
|
||||
func Fatalf(format string, args ...any) {
|
||||
emit(FatalLevel, func() { log.Fatalf(format, args...) })
|
||||
logf(FatalLevel, format, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Fatal logs a message at fatal level.
|
||||
// Fatal logs a message at fatal level, then exits with status 1.
|
||||
func Fatal(arg string) {
|
||||
emit(FatalLevel, func() { log.Fatal(arg) })
|
||||
logf(FatalLevel, "%s", arg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Errorf logs a formatted message at error level.
|
||||
func Errorf(format string, args ...any) {
|
||||
emit(ErrorLevel, func() { log.Errorf(format, args...) })
|
||||
logf(ErrorLevel, format, args...)
|
||||
}
|
||||
|
||||
// Error logs a message at error level.
|
||||
func Error(arg string) {
|
||||
emit(ErrorLevel, func() { log.Error(arg) })
|
||||
logf(ErrorLevel, "%s", arg)
|
||||
}
|
||||
|
||||
// Warnf logs a formatted message at warn level.
|
||||
func Warnf(format string, args ...any) {
|
||||
emit(WarnLevel, func() { log.Warnf(format, args...) })
|
||||
logf(WarnLevel, format, args...)
|
||||
}
|
||||
|
||||
// Warn logs a message at warn level.
|
||||
func Warn(arg string) {
|
||||
emit(WarnLevel, func() { log.Warn(arg) })
|
||||
logf(WarnLevel, "%s", arg)
|
||||
}
|
||||
|
||||
// Infof logs a formatted message at info level.
|
||||
func Infof(format string, args ...any) {
|
||||
emit(InfoLevel, func() { log.Infof(format, args...) })
|
||||
logf(InfoLevel, format, args...)
|
||||
}
|
||||
|
||||
// Info logs a message at info level.
|
||||
func Info(arg string) {
|
||||
emit(InfoLevel, func() { log.Info(arg) })
|
||||
logf(InfoLevel, "%s", arg)
|
||||
}
|
||||
|
||||
// Verbosef logs a formatted message at verbose level.
|
||||
func Verbosef(format string, args ...any) {
|
||||
emit(VerboseLevel, func() { log.Infof(format, args...) })
|
||||
logf(VerboseLevel, format, args...)
|
||||
}
|
||||
|
||||
// Verbose logs a message at verbose level.
|
||||
func Verbose(arg string) {
|
||||
emit(VerboseLevel, func() { log.Info(arg) })
|
||||
logf(VerboseLevel, "%s", arg)
|
||||
}
|
||||
|
||||
// Debugf logs a formatted message at debug level with caller location.
|
||||
@@ -211,20 +306,12 @@ func Debug(arg string) {
|
||||
|
||||
// DebugReal logs at debug level with caller info from the specified stack depth.
|
||||
func DebugReal(arg string, cs int) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
|
||||
if DebugLevel < currentLevel {
|
||||
return
|
||||
}
|
||||
|
||||
_, callerFile, callerLine, ok := runtime.Caller(cs)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
tag := fmt.Sprintf("%s:%d: ", filepath.Base(callerFile), callerLine)
|
||||
log.Debug(tag + arg)
|
||||
logf(DebugLevel, "%s:%d: %s", filepath.Base(callerFile), callerLine, arg)
|
||||
}
|
||||
|
||||
// Dump logs a spew dump of the arguments at debug level.
|
||||
@@ -275,12 +362,19 @@ func GetLevel() Level {
|
||||
|
||||
// Progressf prints a progress message that overwrites the current 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 ...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.
|
||||
func ProgressDone() {
|
||||
// Clear the line with spaces and return to beginning
|
||||
pterm.Print("\r\033[K")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Return to the start of the line and erase it
|
||||
_, _ = fmt.Fprint(stdout, "\r\033[K")
|
||||
}
|
||||
|
||||
+220
-3
@@ -1,12 +1,229 @@
|
||||
package log_test
|
||||
//nolint:testpackage // white-box tests exercise unexported internals
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
log.Init()
|
||||
Init()
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
+23
-8
@@ -3,6 +3,7 @@
|
||||
package mfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -34,7 +35,8 @@ var (
|
||||
errPathDotDot = errors.New("contains '..' segment")
|
||||
errSizeMismatch = errors.New("size mismatch")
|
||||
errNegativeSize = errors.New("size cannot be negative")
|
||||
errEmptyHash = errors.New("hash cannot be nil or empty")
|
||||
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:
|
||||
@@ -227,7 +229,8 @@ func (b *Builder) FileCount() int {
|
||||
|
||||
// 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).
|
||||
// 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
|
||||
// multihash with a digest of at least 32 bytes, as long as SHA-256's.
|
||||
func (b *Builder) AddFileWithHash(
|
||||
path RelFilePath,
|
||||
size FileSize,
|
||||
@@ -243,8 +246,19 @@ func (b *Builder) AddFileWithHash(
|
||||
return errNegativeSize
|
||||
}
|
||||
|
||||
if len(hash) == 0 {
|
||||
return errEmptyHash
|
||||
decoded, err := multihash.Decode(hash)
|
||||
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{
|
||||
@@ -281,8 +295,9 @@ func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
||||
b.signingOptions = opts
|
||||
}
|
||||
|
||||
// Build finalizes the manifest and writes it to the writer.
|
||||
func (b *Builder) Build(w io.Writer) error {
|
||||
// Build finalizes the manifest and writes it to the writer. ctx bounds the
|
||||
// gpg runs that sign the manifest when signing options are set.
|
||||
func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
@@ -308,13 +323,13 @@ func (b *Builder) Build(w io.Writer) error {
|
||||
}
|
||||
|
||||
// Generate outer wrapper
|
||||
err := m.generateOuter()
|
||||
err := m.generateOuter(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: generate outer: %w", err)
|
||||
}
|
||||
|
||||
// Generate final output
|
||||
err = m.generate()
|
||||
err = m.generate(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: generate: %w", err)
|
||||
}
|
||||
|
||||
+108
-34
@@ -3,10 +3,16 @@ package mfer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -40,9 +46,10 @@ func TestBuilderAddFileWithHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBuilder()
|
||||
hash := make([]byte, 34) // SHA256 multihash is 34 bytes
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||
err = b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, b.FileCount())
|
||||
}
|
||||
@@ -50,12 +57,14 @@ func TestBuilderAddFileWithHash(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.Parallel()
|
||||
|
||||
b := NewBuilder()
|
||||
hash := make([]byte, 34)
|
||||
err := b.AddFileWithHash("", 100, ModTime(time.Now()), hash)
|
||||
err := b.AddFileWithHash("", 100, ModTime(time.Now()), sha256Hash)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path")
|
||||
})
|
||||
@@ -64,41 +73,58 @@ func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBuilder()
|
||||
hash := make([]byte, 34)
|
||||
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), hash)
|
||||
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), sha256Hash)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "size")
|
||||
})
|
||||
|
||||
t.Run("nil hash", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBuilder()
|
||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hash")
|
||||
})
|
||||
|
||||
t.Run("empty hash", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBuilder()
|
||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), []byte{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hash")
|
||||
})
|
||||
|
||||
t.Run("valid inputs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBuilder()
|
||||
hash := make([]byte, 34)
|
||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), sha256Hash)
|
||||
require.NoError(t, err)
|
||||
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()), tt.hash)
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
assert.Equal(t, 0, b.FileCount())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -113,7 +139,7 @@ func TestBuilderBuild(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = b.Build(&buf)
|
||||
err = b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have magic bytes
|
||||
@@ -177,7 +203,7 @@ func TestBuilderDeterministicOutput(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := b.Build(&buf)
|
||||
err := b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
return buf.Bytes()
|
||||
@@ -325,7 +351,7 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(&buf))
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
@@ -348,6 +374,31 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}, 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -371,6 +422,29 @@ func TestNewManifestFromReaderTruncated(t *testing.T) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -383,7 +457,7 @@ func TestManifestString(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(&buf))
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
@@ -397,7 +471,7 @@ func TestBuilderBuildEmpty(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := b.Build(&buf)
|
||||
err := b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should still produce valid manifest with 0 files
|
||||
@@ -416,7 +490,7 @@ func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(&buf))
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
@@ -438,7 +512,7 @@ func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(&buf))
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
@@ -464,7 +538,7 @@ func TestBuilderDeterministicFileOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(&buf))
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
+85
-44
@@ -14,7 +14,11 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var errNoSigningPubKey = errors.New("manifest has no signing public key")
|
||||
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.
|
||||
type Result struct {
|
||||
@@ -73,29 +77,51 @@ type Checker struct {
|
||||
fs afero.Fs
|
||||
// manifestPaths is a set of paths in the manifest for quick lookup
|
||||
manifestPaths map[RelFilePath]struct{}
|
||||
// manifestRelPath is the relative path of the manifest file from
|
||||
// basePath (for exclusion)
|
||||
manifestRelPath RelFilePath
|
||||
// manifestInfo is the manifest file, which FindExtraFiles leaves out,
|
||||
// matched with os.SameFile.
|
||||
manifestInfo os.FileInfo
|
||||
// signature info from the manifest
|
||||
signature []byte
|
||||
signer []byte
|
||||
signingPubKey []byte
|
||||
}
|
||||
|
||||
// NewChecker creates a new Checker for the given manifest, base path, and filesystem.
|
||||
// The basePath is the directory relative to which manifest paths are resolved.
|
||||
// If fs is nil, the real filesystem (OsFs) is used.
|
||||
func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, error) {
|
||||
// CheckerOptions configures a Checker.
|
||||
type CheckerOptions struct {
|
||||
// ManifestPath is the manifest file to check against (required).
|
||||
ManifestPath string
|
||||
// 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 {
|
||||
fs = afero.NewOsFs()
|
||||
}
|
||||
|
||||
m, err := NewManifestFromFile(fs, manifestPath)
|
||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{
|
||||
Path: opts.ManifestPath,
|
||||
Fs: fs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(basePath)
|
||||
abs, err := filepath.Abs(opts.BasePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,26 +133,20 @@ func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, er
|
||||
manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
|
||||
}
|
||||
|
||||
// Compute manifest's relative path from basePath for exclusion in FindExtraFiles
|
||||
absManifest, err := filepath.Abs(manifestPath)
|
||||
manifestInfo, err := fs.Stat(opts.ManifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manifestRel, err := filepath.Rel(abs, absManifest)
|
||||
if err != nil {
|
||||
manifestRel = ""
|
||||
}
|
||||
|
||||
return &Checker{
|
||||
basePath: AbsFilePath(abs),
|
||||
files: files,
|
||||
fs: fs,
|
||||
manifestPaths: manifestPaths,
|
||||
manifestRelPath: RelFilePath(manifestRel),
|
||||
signature: m.pbOuter.GetSignature(),
|
||||
signer: m.pbOuter.GetSigner(),
|
||||
signingPubKey: m.pbOuter.GetSigningPubKey(),
|
||||
basePath: AbsFilePath(abs),
|
||||
files: files,
|
||||
fs: fs,
|
||||
manifestPaths: manifestPaths,
|
||||
manifestInfo: manifestInfo,
|
||||
signature: m.pbOuter.GetSignature(),
|
||||
signer: m.pbOuter.GetSigner(),
|
||||
signingPubKey: m.pbOuter.GetSigningPubKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -164,12 +184,12 @@ func (c *Checker) SigningPubKey() []byte {
|
||||
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
||||
// temporary keyring and extracts its fingerprint. This validates the key and
|
||||
// returns its actual fingerprint from the key material itself.
|
||||
func (c *Checker) ExtractEmbeddedSigningKeyFP() (string, error) {
|
||||
func (c *Checker) ExtractEmbeddedSigningKeyFP(ctx context.Context) (string, error) {
|
||||
if len(c.signingPubKey) == 0 {
|
||||
return "", errNoSigningPubKey
|
||||
}
|
||||
|
||||
return gpgExtractPubKeyFingerprint(c.signingPubKey)
|
||||
return gpgExtractPubKeyFingerprint(ctx, c.signingPubKey)
|
||||
}
|
||||
|
||||
// Check verifies all files against the manifest.
|
||||
@@ -247,20 +267,27 @@ func (c *Checker) Check(
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// FindExtraFiles walks the filesystem and reports files not in the manifest,
|
||||
// hidden files and directories included. The manifest file itself is not
|
||||
// reported. Anything the search cannot read, such as a directory that cannot
|
||||
// be listed, is reported with StatusError and the search goes on. Results are
|
||||
// sent to the results channel. The channel is closed when done.
|
||||
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
||||
if results != nil {
|
||||
defer close(results)
|
||||
}
|
||||
|
||||
walkFn := func(walkPath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The search does not follow symlinks, so a base directory named
|
||||
// through one is resolved first. If that fails, the base is searched as
|
||||
// named and the search reports the problem.
|
||||
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 {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -268,15 +295,22 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
rel, err := filepath.Rel(string(c.basePath), walkPath)
|
||||
rel, err := filepath.Rel(root, walkPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip hidden files and directories (dotfiles)
|
||||
if IsHiddenPath(filepath.ToSlash(rel)) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
relPath := RelFilePath(rel)
|
||||
|
||||
// Report what cannot be read, such as a directory that cannot be
|
||||
// listed, and go on with the rest.
|
||||
if walkErr != nil {
|
||||
if results != nil {
|
||||
results <- Result{
|
||||
Path: relPath,
|
||||
Status: StatusError,
|
||||
Message: walkErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -287,10 +321,17 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath := RelFilePath(rel)
|
||||
// A symlink is compared by what it points to, so a manifest reached
|
||||
// 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
|
||||
if relPath == c.manifestRelPath {
|
||||
// Skip the manifest file itself, however its path is spelled
|
||||
if os.SameFile(info, c.manifestInfo) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -308,7 +349,7 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
||||
return nil
|
||||
}
|
||||
|
||||
return afero.Walk(c.fs, string(c.basePath), walkFn)
|
||||
return afero.Walk(c.fs, root, walkFn)
|
||||
}
|
||||
|
||||
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
||||
|
||||
+272
-129
@@ -5,6 +5,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +16,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
testFile1 = "file1.txt"
|
||||
testFile2 = "file2.txt"
|
||||
testExistsFile = "exists.txt"
|
||||
testFile1 = "file1.txt"
|
||||
testFile2 = "file2.txt"
|
||||
testExistsFile = "exists.txt"
|
||||
testManifestPath = "/manifest.mf"
|
||||
testDataDir = "/data"
|
||||
)
|
||||
|
||||
func TestStatusString(t *testing.T) {
|
||||
@@ -61,20 +65,18 @@ func createTestManifest(
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, builder.Build(&buf))
|
||||
require.NoError(t, builder.Build(context.Background(), &buf))
|
||||
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
||||
}
|
||||
|
||||
// createFilesOnDisk creates the given files on the filesystem under
|
||||
// /data.
|
||||
// testDataDir.
|
||||
func createFilesOnDisk(t *testing.T, fs afero.Fs, files map[string][]byte) {
|
||||
t.Helper()
|
||||
|
||||
basePath := "/data"
|
||||
|
||||
for path, content := range files {
|
||||
fullPath := basePath + "/" + path
|
||||
require.NoError(t, fs.MkdirAll(basePath, 0o755))
|
||||
fullPath := testDataDir + "/" + path
|
||||
require.NoError(t, fs.MkdirAll(testDataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
||||
}
|
||||
}
|
||||
@@ -90,9 +92,13 @@ func TestNewChecker(t *testing.T) {
|
||||
testFile1: []byte("hello"),
|
||||
testFile2: []byte("world"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: "/",
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, chk)
|
||||
assert.Equal(t, FileCount(2), chk.FileCount())
|
||||
@@ -102,7 +108,11 @@ func TestNewChecker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := NewChecker("/nonexistent.mf", "/", fs)
|
||||
_, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: "/nonexistent.mf",
|
||||
BasePath: "/",
|
||||
Fs: fs,
|
||||
})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
@@ -111,11 +121,73 @@ func TestNewChecker(t *testing.T) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
|
||||
_, err := NewChecker("/bad.mf", "/", fs)
|
||||
_, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: "/bad.mf",
|
||||
BasePath: "/",
|
||||
Fs: fs,
|
||||
})
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -125,9 +197,13 @@ func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
||||
"medium.txt": []byte("hello world"),
|
||||
"large.txt": bytes.Repeat([]byte("x"), 1000),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: "/",
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, FileCount(3), chk.FileCount())
|
||||
@@ -142,10 +218,14 @@ func TestCheckAllFilesOK(t *testing.T) {
|
||||
testFile1: []byte("content one"),
|
||||
testFile2: []byte("content two"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -172,13 +252,17 @@ func TestCheckMissingFile(t *testing.T) {
|
||||
testExistsFile: []byte("I exist"),
|
||||
"missing.txt": []byte("I don't exist on disk"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
// Only create one file
|
||||
createFilesOnDisk(t, fs, map[string][]byte{
|
||||
testExistsFile: []byte("I exist"),
|
||||
})
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -211,13 +295,17 @@ func TestCheckSizeMismatch(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
testFileName: []byte("original content"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
// Create file with different size
|
||||
createFilesOnDisk(t, fs, map[string][]byte{
|
||||
testFileName: []byte("short"),
|
||||
})
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -237,7 +325,7 @@ func TestCheckHashMismatch(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
testFileName: originalContent,
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
// Create file with same size but different content
|
||||
differentContent := []byte("different contnt") // same length (16 bytes) but different
|
||||
require.Len(t, differentContent, len(originalContent), "test requires same length")
|
||||
@@ -245,7 +333,11 @@ func TestCheckHashMismatch(t *testing.T) {
|
||||
testFileName: differentContent,
|
||||
})
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -265,10 +357,14 @@ func TestCheckWithProgress(t *testing.T) {
|
||||
testFile1: bytes.Repeat([]byte("a"), 100),
|
||||
testFile2: bytes.Repeat([]byte("b"), 200),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -305,10 +401,14 @@ func TestCheckContextCancellation(t *testing.T) {
|
||||
files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000)
|
||||
}
|
||||
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -327,7 +427,7 @@ func TestFindExtraFiles(t *testing.T) {
|
||||
manifestFiles := map[string][]byte{
|
||||
testFile1: []byte("in manifest"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", manifestFiles)
|
||||
createTestManifest(t, fs, testManifestPath, manifestFiles)
|
||||
|
||||
// Disk has file1 and file2
|
||||
createFilesOnDisk(t, fs, map[string][]byte{
|
||||
@@ -335,7 +435,11 @@ func TestFindExtraFiles(t *testing.T) {
|
||||
testFile2: []byte("extra file"),
|
||||
})
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -353,46 +457,120 @@ func TestFindExtraFiles(t *testing.T) {
|
||||
assert.Equal(t, "not in manifest", extras[0].Message)
|
||||
}
|
||||
|
||||
func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
||||
// TestFindExtraFilesReportsHiddenFilesButNotManifest keeps the manifest
|
||||
// inside the checked tree: hidden files and directories are reported, the
|
||||
// manifest is not. The manifest is recognized by file identity, which needs
|
||||
// the real filesystem.
|
||||
func TestFindExtraFilesReportsHiddenFilesButNotManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
manifestFiles := map[string][]byte{
|
||||
testFile1: []byte("in manifest"),
|
||||
}
|
||||
createTestManifest(t, fs, "/data/.index.mf", manifestFiles)
|
||||
createFilesOnDisk(t, fs, map[string][]byte{
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, "index.mf")
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
createTestManifest(t, fs, manifestPath, map[string][]byte{
|
||||
testFile1: []byte("in manifest"),
|
||||
})
|
||||
// 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)
|
||||
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))
|
||||
}
|
||||
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: manifestPath,
|
||||
BasePath: dir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
err = chk.FindExtraFiles(context.Background(), results)
|
||||
require.NoError(t, 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)
|
||||
|
||||
var extras []Result
|
||||
results := make(chan Result, 10)
|
||||
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
||||
|
||||
var extras []RelFilePath
|
||||
for r := range results {
|
||||
extras = append(extras, r)
|
||||
extras = append(extras, r.Path)
|
||||
}
|
||||
|
||||
// Should only report extra.txt, not .hidden, .config/settings, or .index.mf
|
||||
for _, e := range extras {
|
||||
t.Logf("extra: %s", e.Path)
|
||||
assert.Empty(t, extras)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -400,10 +578,14 @@ func TestFindExtraFilesContextCancellation(t *testing.T) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string][]byte{testFileName: []byte("data")}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -419,10 +601,14 @@ func TestCheckNilChannels(t *testing.T) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string][]byte{testFileName: []byte("data")}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not panic with nil channels
|
||||
@@ -435,10 +621,14 @@ func TestFindExtraFilesNilChannel(t *testing.T) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string][]byte{testFileName: []byte("data")}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not panic with nil channel
|
||||
@@ -455,7 +645,7 @@ func TestCheckSubdirectories(t *testing.T) {
|
||||
"dir1/dir2/file2.txt": []byte("content2"),
|
||||
"dir1/dir2/dir3/deep.txt": []byte("deep content"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
|
||||
// Create files with full directory structure
|
||||
for path, content := range files {
|
||||
@@ -465,7 +655,11 @@ func TestCheckSubdirectories(t *testing.T) {
|
||||
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
||||
}
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -493,13 +687,17 @@ func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
||||
testExistsFile: []byte("here"),
|
||||
"missing.txt": []byte("not on disk"),
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
// Only create one file on disk
|
||||
createFilesOnDisk(t, fs, map[string][]byte{
|
||||
testExistsFile: []byte("here"),
|
||||
})
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
@@ -519,77 +717,18 @@ func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
||||
assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR")
|
||||
}
|
||||
|
||||
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{
|
||||
testFile1: []byte("in manifest"),
|
||||
}
|
||||
createTestManifest(t, fs, "/data/.index.mf", files)
|
||||
createFilesOnDisk(t, fs, 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) {
|
||||
t.Parallel()
|
||||
|
||||
// The manifest file itself should never be reported as extra
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string][]byte{
|
||||
testFile1: []byte("content"),
|
||||
}
|
||||
createTestManifest(t, fs, "/data/index.mf", files)
|
||||
createFilesOnDisk(t, fs, 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) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
// Create manifest with no files
|
||||
createTestManifest(t, fs, "/manifest.mf", map[string][]byte{})
|
||||
createTestManifest(t, fs, testManifestPath, map[string][]byte{})
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, FileCount(0), chk.FileCount())
|
||||
@@ -621,10 +760,14 @@ func TestCheckProgressRateLimited(t *testing.T) {
|
||||
files[name] = []byte("content")
|
||||
}
|
||||
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createTestManifest(t, fs, testManifestPath, files)
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestPath: testManifestPath,
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 200)
|
||||
|
||||
@@ -12,6 +12,33 @@ const (
|
||||
// memory.
|
||||
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.
|
||||
decodedFileEntrySize = 160
|
||||
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 336. So its manifests add up to at most about 7.15 times
|
||||
// their size, and this limit is about 12% above that.
|
||||
maxDecodedGrowth = 8
|
||||
)
|
||||
|
||||
+123
-7
@@ -2,6 +2,7 @@ package mfer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/spf13/afero"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"sneak.berlin/go/mfer/internal/bork"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
@@ -26,6 +28,8 @@ var (
|
||||
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 a valid UUID (16 bytes, parseable).
|
||||
@@ -92,7 +96,9 @@ func (m *manifest) verifyOuterIntegrity() error {
|
||||
)
|
||||
}
|
||||
|
||||
// Loading a manifest takes no context; gpgTimeout still bounds gpg.
|
||||
err = gpgVerify(
|
||||
context.Background(),
|
||||
[]byte(sigString),
|
||||
m.pbOuter.GetSignature(),
|
||||
m.pbOuter.GetSigningPubKey(),
|
||||
@@ -111,7 +117,18 @@ func (m *manifest) verifyOuterIntegrity() error {
|
||||
func (m *manifest) decompressInner() ([]byte, error) {
|
||||
bb := bytes.NewBuffer(m.pbOuter.GetInnerMessage())
|
||||
|
||||
zr, err := zstd.NewReader(bb)
|
||||
// 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)
|
||||
}
|
||||
@@ -140,6 +157,85 @@ func (m *manifest) decompressInner() ([]byte, error) {
|
||||
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 {
|
||||
@@ -163,10 +259,16 @@ func (m *manifest) deserializeInner() error {
|
||||
return bork.ErrFileTruncated
|
||||
}
|
||||
|
||||
err = checkDecodedSize(dat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
||||
}
|
||||
|
||||
// Deserialize inner message
|
||||
m.pbInner = new(MFFile)
|
||||
|
||||
err = proto.Unmarshal(dat, m.pbInner)
|
||||
// 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)
|
||||
}
|
||||
@@ -235,7 +337,8 @@ func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
||||
// deserialize outer:
|
||||
m.pbOuter = new(MFFileOuter)
|
||||
|
||||
err = proto.Unmarshal(dat, m.pbOuter)
|
||||
// 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
|
||||
}
|
||||
@@ -249,16 +352,29 @@ func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// NewManifestFromFile reads a manifest from a file path using the given filesystem.
|
||||
// If fs is nil, the real filesystem (OsFs) is used.
|
||||
// ManifestFromFileOptions configures NewManifestFromFile.
|
||||
type ManifestFromFileOptions struct {
|
||||
// Path is the manifest file to read (required).
|
||||
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(fs afero.Fs, path string) (*manifest, error) {
|
||||
func NewManifestFromFile(opts *ManifestFromFileOptions) (*manifest, error) {
|
||||
if opts == nil || opts.Path == "" {
|
||||
return nil, errManifestPathEmpty
|
||||
}
|
||||
|
||||
fs := opts.Fs
|
||||
if fs == nil {
|
||||
fs = afero.NewOsFs()
|
||||
}
|
||||
|
||||
f, err := fs.Open(path)
|
||||
f, err := fs.Open(opts.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,12 +3,17 @@ package mfer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/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"
|
||||
@@ -113,16 +118,125 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Entries of a path, an empty hash, an empty MIME type and empty modification
|
||||
// and change times are counted at 416 bytes each (160 + 112 + 16 + 64 + 64)
|
||||
// and take 16 bytes plus the path to encode. A 35-character path makes that
|
||||
// 51 bytes, about 8.2 times: refused, and leaving any one of the five
|
||||
// uncounted, even the MIME type, brings it under 8. A 37-character path makes
|
||||
// it 53 bytes, about 7.8 times: loaded.
|
||||
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
pathLen int
|
||||
refused bool
|
||||
}{
|
||||
{35, true},
|
||||
{37, 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)), 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 := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256
|
||||
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{}, hash))
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(&buf))
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(buf.Bytes()))
|
||||
require.NoError(t, err)
|
||||
|
||||
+4
-2
@@ -2,6 +2,7 @@
|
||||
package mfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -80,6 +81,7 @@ func TestSerializeInternalErrorMessagesVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := &manifest{}
|
||||
require.EqualError(t, m.generate(), "internal error: pbInner not set")
|
||||
require.EqualError(t, m.generateOuter(), "internal error")
|
||||
require.EqualError(t, m.generate(context.Background()),
|
||||
"internal error: pbInner not set")
|
||||
require.EqualError(t, m.generateOuter(context.Background()), "internal error")
|
||||
}
|
||||
|
||||
+48
-15
@@ -10,9 +10,21 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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
|
||||
@@ -66,8 +78,20 @@ func gpgArgs(opts []string, positional ...string) []string {
|
||||
}
|
||||
|
||||
// runGPG runs the gpg binary in batch mode with the given arguments and
|
||||
// optional stdin, returning captured stdout and stderr.
|
||||
func runGPG(stdin io.Reader, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||
// 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
|
||||
@@ -76,7 +100,8 @@ func runGPG(stdin io.Reader, args ...string) (*bytes.Buffer, *bytes.Buffer, erro
|
||||
// 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
|
||||
context.Background(), "gpg", fullArgs...)
|
||||
ctx, "gpg", fullArgs...)
|
||||
cmd.WaitDelay = gpgWaitDelay
|
||||
cmd.Stdin = stdin
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -85,6 +110,14 @@ func runGPG(stdin io.Reader, args ...string) (*bytes.Buffer, *bytes.Buffer, erro
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
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
|
||||
}
|
||||
@@ -105,8 +138,8 @@ func parseFingerprint(colonOutput string) (string, bool) {
|
||||
|
||||
// gpgSign creates a detached signature of the data using the specified key.
|
||||
// Returns the armored detached signature.
|
||||
func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||
stdout, stderr, err := runGPG(bytes.NewReader(data),
|
||||
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),
|
||||
@@ -120,8 +153,8 @@ func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||
|
||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||
// Returns the armored public key.
|
||||
func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||
stdout, stderr, err := runGPG(nil,
|
||||
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
||||
stdout, stderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -136,8 +169,8 @@ func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||
}
|
||||
|
||||
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||
func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||
stdout, stderr, err := runGPG(nil,
|
||||
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
||||
stdout, stderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -157,7 +190,7 @@ func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
||||
// and extracts its fingerprint. This verifies the key is valid and returns
|
||||
// the actual fingerprint from the key material.
|
||||
func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
|
||||
// Create temporary directory for GPG operations
|
||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
||||
if err != nil {
|
||||
@@ -181,7 +214,7 @@ func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
}
|
||||
|
||||
// Import the public key into the temporary keyring
|
||||
_, importStderr, err := runGPG(nil,
|
||||
_, importStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -191,7 +224,7 @@ func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
}
|
||||
|
||||
// List keys to get fingerprint
|
||||
listStdout, listStderr, err := runGPG(nil,
|
||||
listStdout, listStderr, err := runGPG(ctx, nil,
|
||||
"--homedir", tmpDir,
|
||||
"--with-colons",
|
||||
"--fingerprint",
|
||||
@@ -212,7 +245,7 @@ func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
|
||||
// gpgVerify verifies a detached signature against data using the provided public key.
|
||||
// It creates a temporary keyring to import the public key for verification.
|
||||
func gpgVerify(data, signature, pubKey []byte) error {
|
||||
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
|
||||
// Create temporary directory for GPG operations
|
||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
||||
if err != nil {
|
||||
@@ -252,7 +285,7 @@ func gpgVerify(data, signature, pubKey []byte) error {
|
||||
}
|
||||
|
||||
// Import the public key into the temporary keyring
|
||||
_, importStderr, err := runGPG(nil,
|
||||
_, importStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -262,7 +295,7 @@ func gpgVerify(data, signature, pubKey []byte) error {
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
_, verifyStderr, err := runGPG(nil,
|
||||
_, verifyStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
||||
sigFile, dataFile)...,
|
||||
)
|
||||
|
||||
+116
-20
@@ -4,11 +4,15 @@ package mfer
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -43,8 +47,11 @@ Expire-Date: 0
|
||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
||||
cmd := exec.CommandContext(context.Background(), "gpg",
|
||||
cmd := exec.CommandContext(ctx, "gpg",
|
||||
"--batch", "--gen-key", paramsFile)
|
||||
|
||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||
@@ -55,7 +62,7 @@ Expire-Date: 0
|
||||
}
|
||||
|
||||
// Get the key fingerprint
|
||||
cmd = exec.CommandContext(context.Background(), "gpg",
|
||||
cmd = exec.CommandContext(ctx, "gpg",
|
||||
"--list-keys", "--with-colons", "test@mfer.test")
|
||||
|
||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||
@@ -90,7 +97,7 @@ func TestGPGSign(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data to sign")
|
||||
sig, err := gpgSign(data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, sig)
|
||||
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
||||
@@ -101,7 +108,7 @@ func TestGPGExportPublicKey(t *testing.T) {
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
pubKey, err := gpgExportPublicKey(keyID)
|
||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, pubKey)
|
||||
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||
@@ -112,7 +119,7 @@ func TestGPGGetKeyFingerprint(t *testing.T) {
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
fingerprint, err := gpgGetKeyFingerprint(keyID)
|
||||
fingerprint, err := gpgGetKeyFingerprint(context.Background(), keyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, fingerprint)
|
||||
// The fingerprint should be 40 hex chars
|
||||
@@ -146,12 +153,12 @@ func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
|
||||
_, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
pubKey, err := gpgExportPublicKey(GPGKeyID("--version"))
|
||||
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(GPGKeyID("--version"))
|
||||
fpr, err := gpgGetKeyFingerprint(context.Background(), GPGKeyID("--version"))
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
||||
}
|
||||
@@ -162,7 +169,8 @@ func TestGPGSignInvalidKey(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data")
|
||||
_, err := gpgSign(data, GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
||||
_, err := gpgSign(context.Background(), data,
|
||||
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -185,7 +193,7 @@ func TestBuilderWithSigning(t *testing.T) {
|
||||
// Build the manifest
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = b.Build(&buf)
|
||||
err = b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the manifest and verify signature fields are populated
|
||||
@@ -251,14 +259,14 @@ func TestGPGVerify(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data to sign and verify")
|
||||
sig, err := gpgSign(data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey, err := gpgExportPublicKey(keyID)
|
||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the signature
|
||||
err = gpgVerify(data, sig, pubKey)
|
||||
err = gpgVerify(context.Background(), data, sig, pubKey)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -267,15 +275,15 @@ func TestGPGVerifyInvalidSignature(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data to sign")
|
||||
sig, err := gpgSign(data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey, err := gpgExportPublicKey(keyID)
|
||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to verify with different data - should fail
|
||||
wrongData := []byte("different data")
|
||||
err = gpgVerify(wrongData, sig, pubKey)
|
||||
err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -284,12 +292,12 @@ func TestGPGVerifyBadPublicKey(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data")
|
||||
sig, err := gpgSign(data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to verify with invalid public key - should fail
|
||||
badPubKey := []byte("not a valid public key")
|
||||
err = gpgVerify(data, sig, badPubKey)
|
||||
err = gpgVerify(context.Background(), data, sig, badPubKey)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -312,7 +320,7 @@ func TestManifestSignatureVerification(t *testing.T) {
|
||||
// Build the manifest
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = b.Build(&buf)
|
||||
err = b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the manifest - signature should be verified during load
|
||||
@@ -341,7 +349,7 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = b.Build(&buf)
|
||||
err = b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tamper with the signature by replacing some bytes
|
||||
@@ -375,7 +383,7 @@ func TestBuilderWithoutSigning(t *testing.T) {
|
||||
// Build the manifest
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = b.Build(&buf)
|
||||
err = b.Build(context.Background(), &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the manifest and verify signature fields are empty
|
||||
@@ -390,3 +398,91 @@ func TestBuilderWithoutSigning(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package mfer
|
||||
|
||||
//go:generate protoc ./mf.proto --go_out=paths=source_relative:.
|
||||
+1
-1
@@ -587,7 +587,7 @@ const file_mf_proto_rawDesc = "" +
|
||||
"\fVERSION_NONE\x10\x00\x12\x0f\n" +
|
||||
"\vVERSION_ONE\x10\x01B\f\n" +
|
||||
"\n" +
|
||||
"_createdAtB\x1dZ\x1bgit.eeqj.de/sneak/mfer/mferb\x06proto3"
|
||||
"_createdAtB\x1bZ\x19sneak.berlin/go/mfer/mferb\x06proto3"
|
||||
|
||||
var (
|
||||
file_mf_proto_rawDescOnce sync.Once
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "git.eeqj.de/sneak/mfer/mfer";
|
||||
option go_package = "sneak.berlin/go/mfer/mfer";
|
||||
|
||||
message Timestamp {
|
||||
int64 seconds = 1;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
fa6fceaba5553c8667c631994535fe5c307c8adb19f3ad289babf79a0f438650 mf.proto
|
||||
@@ -0,0 +1,29 @@
|
||||
package mfer_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
+23
-3
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -57,6 +58,8 @@ type ScannerOptions struct {
|
||||
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.
|
||||
@@ -75,6 +78,7 @@ type Scanner struct {
|
||||
totalBytes FileSize // cached sum of all file sizes
|
||||
options *ScannerOptions
|
||||
fs afero.Fs
|
||||
excluded []fs.FileInfo // the files named in ExcludePaths that exist
|
||||
}
|
||||
|
||||
// NewScanner creates a new Scanner with default options.
|
||||
@@ -93,11 +97,22 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
|
||||
fs = afero.NewOsFs()
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
s := &Scanner{
|
||||
files: make([]*FileEntry, 0),
|
||||
options: opts,
|
||||
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.
|
||||
@@ -283,8 +298,7 @@ func (s *Scanner) ToManifest(
|
||||
}
|
||||
|
||||
// Build and write manifest
|
||||
//nolint:contextcheck // Build's GPG signing exec is not cancellable by design
|
||||
return builder.Build(w)
|
||||
return builder.Build(ctx, w)
|
||||
}
|
||||
|
||||
// configureBuilder constructs a manifest builder configured from the
|
||||
@@ -436,6 +450,12 @@ func (s *Scanner) enumerateFileWithInfo(
|
||||
info = realInfo
|
||||
}
|
||||
|
||||
for _, excluded := range s.excluded {
|
||||
if os.SameFile(info, excluded) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
entry := &FileEntry{
|
||||
Path: RelFilePath(cleanPath),
|
||||
AbsPath: AbsFilePath(absPath),
|
||||
|
||||
+4
-31
@@ -304,46 +304,19 @@ func TestScannerEnumerateFS(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)
|
||||
|
||||
// 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")
|
||||
}
|
||||
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
|
||||
}
|
||||
|
||||
func TestSendStatusNilChannel(t *testing.T) {
|
||||
|
||||
+9
-8
@@ -2,6 +2,7 @@ package mfer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -50,13 +51,13 @@ func newTimestampFromTime(t time.Time) *Timestamp {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manifest) generate() error {
|
||||
func (m *manifest) generate(ctx context.Context) error {
|
||||
if m.pbInner == nil {
|
||||
return errInnerNotSet
|
||||
}
|
||||
|
||||
if m.pbOuter == nil {
|
||||
e := m.generateOuter()
|
||||
e := m.generateOuter(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func (m *manifest) generate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manifest) generateOuter() error {
|
||||
func (m *manifest) generateOuter(ctx context.Context) error {
|
||||
if m.pbInner == nil {
|
||||
return errInternal
|
||||
}
|
||||
@@ -135,7 +136,7 @@ func (m *manifest) generateOuter() error {
|
||||
|
||||
// Sign the manifest if signing options are provided
|
||||
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
||||
return m.signOuter()
|
||||
return m.signOuter(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -143,27 +144,27 @@ func (m *manifest) generateOuter() error {
|
||||
|
||||
// signOuter signs the outer message with the configured GPG key and
|
||||
// embeds the signature, signer fingerprint, and public key.
|
||||
func (m *manifest) signOuter() error {
|
||||
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([]byte(sigString), m.signingOptions.KeyID)
|
||||
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(m.signingOptions.KeyID)
|
||||
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(m.signingOptions.KeyID)
|
||||
pubKey, err := gpgExportPublicKey(ctx, m.signingOptions.KeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to export public key: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
[]byte("")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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'")
|
||||
@@ -0,0 +1,2 @@
|
||||
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'")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 ")
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
[]byte("ZNAV")
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
[]byte("ZNAVSRFG")
|
||||
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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
@@ -0,0 +1,2 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
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
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "mfer",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
|
||||
+60
-11
@@ -13,11 +13,15 @@ set -eu
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-07-06. Never "latest" or "lts"; exact versions.
|
||||
NODE_VERSION="22.17.0"
|
||||
# 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"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
@@ -74,9 +78,11 @@ verify_sha256() {
|
||||
fi
|
||||
}
|
||||
|
||||
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||
# 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\" && $*"
|
||||
bash -c ". \"\$HOME/.nvm/nvm.sh\" --no-use && $*"
|
||||
}
|
||||
|
||||
ensure_nvm() {
|
||||
@@ -122,6 +128,48 @@ install_js_deps() {
|
||||
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.
|
||||
ensure_protoc() {
|
||||
dir="$ROOT/bin/protoc"
|
||||
if [ "$("$dir/bin/protoc" --version 2>/dev/null)" = \
|
||||
"libprotoc $PROTOC_VERSION" ]; then
|
||||
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"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
@@ -132,21 +180,22 @@ main() {
|
||||
# ---- 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, whose integrity
|
||||
# hashes --frozen-lockfile enforces.
|
||||
# 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
|
||||
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
|
||||
# package: download a specific release archive from GitHub and
|
||||
# verify its hash (verify_sha256), never curl | sh.
|
||||
if missing golangci-lint; then
|
||||
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
|
||||
fi
|
||||
# No golangci-lint: script/lint runs it in Docker only.
|
||||
go mod download
|
||||
# gofumpt and protoc-gen-go: bin/tools/go.mod pins them, and
|
||||
# script/gofumpt and script/generate build them from there.
|
||||
(cd "$ROOT/bin/tools" && go mod download)
|
||||
ensure_protoc
|
||||
|
||||
# ---- Python repos ----
|
||||
# if missing python3; then pkg_install python3 python3 python3 python3; fi
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/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 "$@"
|
||||
+3
-14
@@ -5,22 +5,11 @@ set -eu
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
# Regenerate mfer/mf.pb.go from mfer/mf.proto if it is missing or stale
|
||||
# (mirrors the old Makefile prerequisite; the generated file is
|
||||
# committed, so this is normally a no-op).
|
||||
ensure_pb() {
|
||||
if [ ! -f mfer/mf.pb.go ] ||
|
||||
[ -n "$(find mfer/mf.proto -newer mfer/mf.pb.go 2>/dev/null)" ]; then
|
||||
(cd mfer && go generate .)
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
ensure_pb
|
||||
gofumpt -l -w mfer internal cmd
|
||||
golangci-lint run --fix
|
||||
# Markdown and JSON, over the same file set script/fmt-check verifies.
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
#!/bin/sh
|
||||
# script/fmt-check: check formatting (read-only). Same scope as
|
||||
# script/fmt, but fails instead of writing: Go via script/fmt-check-go,
|
||||
# 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/fmt-check-go"
|
||||
"$SCRIPT_DIR/gofumpt" --check
|
||||
"$SCRIPT_DIR/prettier" --check
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fmt-check-go: check Go formatting (read-only). Split out from
|
||||
# script/fmt-check so the Docker lint stage, whose image has no node and
|
||||
# therefore no prettier, can run the Go half on its own.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Regenerate mfer/mf.pb.go from mfer/mf.proto if it is missing or stale
|
||||
# (mirrors the old Makefile prerequisite; the generated file is
|
||||
# committed, so this is normally a no-op).
|
||||
ensure_pb() {
|
||||
if [ ! -f mfer/mf.pb.go ] ||
|
||||
[ -n "$(find mfer/mf.proto -newer mfer/mf.pb.go 2>/dev/null)" ]; then
|
||||
(cd mfer && go generate .)
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
ensure_pb
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "gofmt: files need formatting:" >&2
|
||||
gofmt -l . >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/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 "$@"
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/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 that bin/tools/go.mod pins. Another version of either
|
||||
# writes a different mf.pb.go.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# The protoc version 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"
|
||||
|
||||
# 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 left from before the 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
|
||||
# `go tool -n` builds protoc-gen-go from bin/tools and prints where the
|
||||
# binary is, without running it.
|
||||
plugin="$(cd "$ROOT/bin/tools" && go tool -n protoc-gen-go)"
|
||||
|
||||
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="$plugin" \
|
||||
--go_out=paths=source_relative:. ./mf.proto
|
||||
echo "$proto_hash" >mf.proto.sha256
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/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, and so does the Docker lint
|
||||
# stage, so what gets formatted and what gets verified cannot drift
|
||||
# apart.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# The gofumpt version is the one bin/tools/go.mod pins. `go tool` run in
|
||||
# bin/tools builds it from source checked against the hashes in
|
||||
# bin/tools/go.sum, so neither a developer machine nor the lint image needs
|
||||
# it installed.
|
||||
|
||||
usage() {
|
||||
echo "usage: script/gofumpt --write|--check" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
main() {
|
||||
[ "$#" -eq 1 ] || usage
|
||||
cd "$ROOT/bin/tools"
|
||||
# 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) go tool 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="$(go tool 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 "$@"
|
||||
+11
-8
@@ -1,17 +1,20 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter.
|
||||
# script/lint: run golangci-lint, in Docker only. Builds the lint stage of
|
||||
# the Dockerfile, whose build runs the linter, so a successful build is a
|
||||
# clean lint. --no-cache because a cached build runs no linter. The image
|
||||
# is removed afterwards, whatever the outcome.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
golangci-lint run
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "gofmt: files need formatting:" >&2
|
||||
gofmt -l . >&2
|
||||
exit 1
|
||||
fi
|
||||
# Tagged per run, so concurrent runs never remove each other's image.
|
||||
image="$("$SCRIPT_DIR/projectname")-lint:$$"
|
||||
# A failed build leaves no image, so there is nothing to remove then.
|
||||
trap 'docker image rm "$image" >/dev/null 2>&1 || true' EXIT INT TERM
|
||||
docker build --no-cache --target lint -t "$image" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+24
-24
@@ -18,24 +18,9 @@ usage() {
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Prefer the version pinned by package.json/yarn.lock so that CI and
|
||||
# developer machines format identically. Fall back to a prettier on PATH,
|
||||
# but say so, because a different version formats differently.
|
||||
find_prettier() {
|
||||
if [ -x "$ROOT/node_modules/.bin/prettier" ]; then
|
||||
printf '%s\n' "$ROOT/node_modules/.bin/prettier"
|
||||
return 0
|
||||
fi
|
||||
if command -v prettier >/dev/null 2>&1; then
|
||||
echo "prettier: node_modules/.bin/prettier is absent; using the" \
|
||||
"prettier on PATH, which may be a different version than the" \
|
||||
"one pinned in package.json. Run script/bootstrap to install" \
|
||||
"the pinned version." >&2
|
||||
command -v prettier
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
# 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
|
||||
@@ -46,10 +31,26 @@ main() {
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if ! prettier_bin="$(find_prettier)"; then
|
||||
echo "prettier: not found." >&2
|
||||
echo " Install it with: script/bootstrap" >&2
|
||||
echo " (installs the version pinned in package.json/yarn.lock)" >&2
|
||||
# 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
|
||||
|
||||
@@ -62,8 +63,7 @@ main() {
|
||||
# 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_bin" "$mode" "**/*.md"
|
||||
"$prettier_bin" "$mode" "**/*.json"
|
||||
"$PRETTIER" "$mode" "**/*.md" "**/*.json"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
-11
@@ -4,19 +4,8 @@ set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Regenerate mfer/mf.pb.go from mfer/mf.proto if it is missing or stale
|
||||
# (mirrors the old Makefile prerequisite; the generated file is
|
||||
# committed, so this is normally a no-op).
|
||||
ensure_pb() {
|
||||
if [ ! -f mfer/mf.pb.go ] ||
|
||||
[ -n "$(find mfer/mf.proto -newer mfer/mf.pb.go 2>/dev/null)" ]; then
|
||||
(cd mfer && go generate .)
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
ensure_pb
|
||||
go test -timeout 30s -race -cover ./... ||
|
||||
{
|
||||
echo "--- Rerunning with -v for details ---"
|
||||
|
||||
Reference in New Issue
Block a user