Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114ba0a29f | ||
|
|
64eb5cbd40 | ||
|
|
45eac1f6f8 | ||
|
|
b91e92b070 | ||
|
|
a2732cf8da |
@@ -16,6 +16,7 @@ linters:
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
settings:
|
||||
|
||||
+7
-2
@@ -14,7 +14,9 @@ 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
|
||||
# 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
|
||||
@@ -67,7 +69,10 @@ 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 -tags urfave_cli_no_docs -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
|
||||
COPY --from=builder /mfer /mfer
|
||||
|
||||
@@ -58,9 +58,6 @@ fmt-check-md:
|
||||
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 .
|
||||
|
||||
|
||||
@@ -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,36 @@ 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
|
||||
go build -o bin/mfer ./cmd/mfer
|
||||
```
|
||||
|
||||
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
|
||||
# is missing or corrupted.
|
||||
bin/mfer check .index.mf
|
||||
|
||||
# Download and cryptographically verify a tree published over HTTP: mfer
|
||||
# fetches <url>/index.mf, then downloads every file it lists.
|
||||
bin/mfer fetch 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,9 +65,9 @@ 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 (Go, Go module download, and
|
||||
node/yarn plus the prettier version pinned in `package.json`/`yarn.lock`),
|
||||
idempotently; 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
|
||||
@@ -47,9 +77,11 @@ provide:
|
||||
- `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` and verify `gofmt` cleanliness
|
||||
- `script/fmt` — format all code and docs (writes): `gofumpt`,
|
||||
`golangci-lint run --fix`, and `script/prettier --write`
|
||||
- `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): `gofumpt` and
|
||||
`script/prettier --write`
|
||||
- `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
|
||||
@@ -75,17 +107,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
|
||||
@@ -109,7 +142,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.
|
||||
|
||||
@@ -141,6 +174,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
|
||||
@@ -274,9 +329,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
|
||||
|
||||
|
||||
+40
-24
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -425,36 +426,51 @@ func TestGPGTimeoutKillsGPG(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
|
||||
}
|
||||
|
||||
// TestGPGTimeoutWhenChildHoldsOutput uses a fake gpg that runs sleep as a
|
||||
// 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 shortly after the deadline
|
||||
// instead of waiting for sleep to exit. The fake gpg writes the process ID
|
||||
// of sleep to a file so that the test can kill it before returning.
|
||||
func TestGPGTimeoutWhenChildHoldsOutput(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
|
||||
// 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 3 &\necho $! >'"+pidFile+"'\nwait\n"))
|
||||
t.Cleanup(func() {
|
||||
pid, err := os.ReadFile(pidFile) //nolint:gosec // G304: path inside t.TempDir()
|
||||
require.NoError(t, err)
|
||||
"#!/bin/sh\nsleep 60 &\necho $! >'"+pidPipe+"'\nwait\n"))
|
||||
|
||||
n, err := strconv.Atoi(strings.TrimSpace(string(pid)))
|
||||
require.NoError(t, err)
|
||||
|
||||
sleep, err := os.FindProcess(n)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sleep.Kill())
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.Less(t, time.Since(start), 3*time.Second,
|
||||
"the call waited for the child holding gpg's output to exit")
|
||||
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
|
||||
|
||||
+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) {
|
||||
|
||||
+1
-6
@@ -140,12 +140,7 @@ main() {
|
||||
|
||||
# ---- 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
|
||||
|
||||
# ---- Python repos ----
|
||||
|
||||
@@ -19,7 +19,6 @@ 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.
|
||||
"$SCRIPT_DIR/prettier" --write
|
||||
}
|
||||
|
||||
+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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user