Author SHA1 Message Date
sneak 23dc6dd49d Give -v to verbose, move --version to -V (closes #64)
check / check (push) Failing after 1s
urfave/cli's built-in version flag claimed -v by default, colliding with
the -v verbose alias used on every subcommand; "mfer -v --version" failed
to parse with an internal-sounding parser error. Verbose is the more
common meaning of -v in tools that offer both, so verbose now keeps -v at
the root and on every subcommand, and the version flag takes the capital
-V (--version still works).

User-visible change: -v alone now prints help with verbose logging rather
than the version; use -V or --version for the version.

The --version flag and the version subcommand now share one printer, so
they emit identical output. Adds tests covering -v, --verbose, --version,
-v --version, and --verbose --version (exit code and output).

Model: opus-4-8
2026-09-21 07:48:12 +00:00
35 changed files with 180 additions and 657 deletions
+2 -62
View File
@@ -1,64 +1,4 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
.git/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][dD]25519
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repo's own host-built archives (Makefile).
*.tmp *.tmp
*.dockerimage *.dockerimage
.git
node_modules
-12
View File
@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab
+2 -21
View File
@@ -10,24 +10,5 @@ modcache.tzst
# Generated manifest files # Generated manifest files
.index.mf .index.mf
# Secrets # Stale files
.env .drone.yml
.env.*
*.key
*.pem
# OS files
.DS_Store
Thumbs.db
# Editor files
*.swp
*.swo
*~
.idea/
.vscode/
# Go build artifacts
*.log
*.out
*.test
+1 -20
View File
@@ -48,26 +48,7 @@ COPY . .
RUN touch mfer/mf.pb.go RUN touch mfer/mf.pb.go
RUN make test RUN make test
RUN cd cmd/mfer && go build -tags urfave_cli_no_docs -o /mfer .
# A build context sent as a tar archive, as upaas sends it, keeps its files'
# owners, and git refuses to read a checkout owned by another user.
RUN git config --system --add safe.directory /src
# The revision `mfer version` prints, stamped into main.Gitrev: the VERSION
# build argument when one is given (script/docker passes one), otherwise
# `git describe --tags --always` of the .git the build context carries: the
# tag on a tagged commit, tag-N-gHASH on a commit after one, the short commit
# when no tag is reachable. git ships in this base image. A context that
# carries .git and still yields no version fails the build.
ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "no version could be derived although the build context carries .git" >&2; \
exit 1; \
fi; \
cd cmd/mfer && \
go build -tags urfave_cli_no_docs -ldflags "-X main.Gitrev=$version" -o /mfer .
FROM scratch FROM scratch
COPY --from=builder /mfer /mfer COPY --from=builder /mfer /mfer
+1 -4
View File
@@ -13,7 +13,7 @@ GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Gitrev=$(GITREV_BUILD) GOLDFLAGS += -X main.Gitrev=$(GITREV_BUILD)
GOFLAGS := -ldflags "$(GOLDFLAGS)" GOFLAGS := -ldflags "$(GOLDFLAGS)"
.PHONY: bootstrap setup docker default run ci test fuzz check lint fmt fmt-check fmt-check-go fmt-check-md hooks fixme .PHONY: bootstrap setup docker default run ci test check lint fmt fmt-check fmt-check-go fmt-check-md hooks fixme
default: fmt test default: fmt test
@@ -32,9 +32,6 @@ ci: test
test: test:
@script/test @script/test
fuzz:
@script/fuzz
$(PROTOC_GEN_GO): $(PROTOC_GEN_GO):
test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
+4 -8
View File
@@ -23,9 +23,8 @@ javascript library is planned.
# Build Status # Build Status
CI runs `script/cibuild`, which builds the Docker image with `--no-cache`, so CI runs via `script/cibuild` (`docker build .`), which executes `make check`
the formatting, lint and test steps in the `Dockerfile` run on every build. The (formatting, linting, tests). The `main` branch must always be green.
`main` branch must always be green.
# Entrypoints # Entrypoints
@@ -44,9 +43,6 @@ provide:
such as `script/docker` such as `script/docker`
- `script/test` — run the test suite (`go test`), regenerating the protobuf code - `script/test` — run the test suite (`go test`), regenerating the protobuf code
first if it is stale first if it is stale
- `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/lint` — run `golangci-lint` and verify `gofmt` cleanliness
- `script/fmt` — format all code and docs (writes): `gofumpt`, - `script/fmt` — format all code and docs (writes): `gofumpt`,
`golangci-lint run --fix`, and `script/prettier --write` `golangci-lint run --fix`, and `script/prettier --write`
@@ -60,8 +56,8 @@ provide:
Docker lint stage, whose image has no node Docker lint stage, whose image has no node
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check` - `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: builds the image with the same command as - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
`script/docker`, uncached, so the checks in the Dockerfile run every time checks)
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then - `script/precommit` — pre-commit checks: `go mod tidy` verification, then
`script/check` `script/check`
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that runs
+4 -29
View File
@@ -24,34 +24,8 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
# Completed Steps # Completed Steps
- 2026-10-03: added `FuzzNewManifestFromReader` and its seed corpus, which - 2026-09-21: fixed the `-v` collision between `--verbose` and `--version`;
`make test` runs, plus `make fuzz` for a one-minute run by hand; the zstd verbose owns `-v`, and version answers to `--version` and `-V` (#64)
decoder now has `MaxDecompressedSize` as its limit, so a frame claiming a
large size can no longer make the parser allocate that size (#65)
- 2026-10-03: `script/cibuild` builds the image with the same command as
`script/docker`, `--no-cache` included, so the checks in the Dockerfile run on
every build, also on an unchanged tree (#89)
- 2026-10-03: `fetch` removes whatever sits at a file's temp name and then
creates the temp file only if that name is free, so a hard link left there
cannot make it write into a file outside the destination directory (#115)
- 2026-10-03: `fetch` refuses any manifest path that runs through a symlink
already in the destination directory, checked before each of its writes
(directories, temp file, rename), so such a symlink cannot send a write
outside it (#86)
- 2026-10-02: a plain `docker build .` of a clone now stamps the tag or short
commit into `mfer version` instead of nothing: `.dockerignore` sends `.git`
(not `.git/config`), and the build stage takes the `VERSION` build argument,
otherwise `git describe --tags --always`, failing if `.git` is present and no
version comes out. `script/docker` is the canonical copy, which passes
`VERSION`; `bin/gitrev.sh` uses `--tags` too (#112)
- 2026-09-21: validate manifest entry paths on deserialize so untrusted `.mf`
files cannot make `Checker` stat or read outside `basePath` (#61)
- 2026-09-21: rewrote `script/test` to the canonical pattern (30s timeout,
`-race -cover`, quiet-first with verbose-on-failure rerun) and fixed the
process-global logger data race it surfaced (#67)
- 2026-09-21: added the canonical `.editorconfig`, made `.gitignore` cover
secrets, OS, editor, and Go artifacts, and removed the dead Drone CI
references from `.gitignore` and `bin/gitrev.sh` (#72)
- 2026-08-09: added `.prettierrc`/`.prettierignore`, gave `script/fmt` and - 2026-08-09: added `.prettierrc`/`.prettierignore`, gave `script/fmt` and
`script/fmt-check` one shared prettier file set via `script/prettier`, dropped `script/fmt-check` one shared prettier file set via `script/prettier`, dropped
the `|| true` that hid prettier failures, and added a node-based Dockerfile the `|| true` that hid prettier failures, and added a node-based Dockerfile
@@ -90,6 +64,7 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
- Move FORMAT.md from repo root to docs/ and update the AGENTS.md reference - Move FORMAT.md from repo root to docs/ and update the AGENTS.md reference
- Pin Makefile-installed Go tools (`protoc-gen-go@v1.28.1`, - Pin Makefile-installed Go tools (`protoc-gen-go@v1.28.1`,
`golangci-lint@v2.12.2`) by module hash, not mutable tag `golangci-lint@v2.12.2`) by module hash, not mutable tag
- Set `make test` timeout to 30s (currently 10s)
- Add explicit README "Rationale" heading (content exists under other - Add explicit README "Rationale" heading (content exists under other
names); name the author in the README Description first line names); name the author in the README Description first line
- Reconcile root-level AGENTS.md with directory-hygiene policy (keep or - Reconcile root-level AGENTS.md with directory-hygiene policy (keep or
@@ -127,7 +102,7 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
rate-limit Checker progress output; add --deterministic flag or default; rate-limit Checker progress output; add --deterministic flag or default;
wire top-level --version properly wire top-level --version properly
- Testing: - Testing:
- End-to-end tests for freshen and fetch - Fuzz NewManifestFromReader; end-to-end tests for freshen and fetch
- Documentation: - Documentation:
- Promote docs/FORMAT.md as primary spec reference; audit error messages; - Promote docs/FORMAT.md as primary spec reference; audit error messages;
document the signature scheme fully document the signature scheme fully
+6 -1
View File
@@ -1,7 +1,12 @@
#!/bin/bash #!/bin/bash
# #
if [[ ! -z "$DRONE_COMMIT_SHA" ]]; then
echo "${DRONE_COMMIT_SHA:0:7}"
exit 0
fi
if [[ ! -z "$GITREV" ]]; then if [[ ! -z "$GITREV" ]]; then
echo $GITREV echo $GITREV
else else
git describe --tags --always --dirty=-dirty git describe --always --dirty=-dirty
fi fi
+74 -12
View File
@@ -27,6 +27,7 @@ const (
testManifest = "/manifest.mf" testManifest = "/manifest.mf"
testFlagBase = "--base" testFlagBase = "--base"
testFlagNoExtra = "--no-extra-files" testFlagNoExtra = "--no-extra-files"
testFlagVersion = "--version"
) )
var errSimulatedWrite = errors.New("simulated write failure") var errSimulatedWrite = errors.New("simulated write failure")
@@ -102,7 +103,7 @@ func TestVersionCommand(t *testing.T) {
t.Parallel() t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
opts := testOpts([]string{testApp, "version"}, fs) opts := testOpts([]string{testApp, cmdVersion}, fs)
exitCode := runCLI(opts) exitCode := runCLI(opts)
@@ -113,6 +114,69 @@ func TestVersionCommand(t *testing.T) {
assert.Contains(t, stdout, "abc123") 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")
assert.Empty(t, testStderr(t, opts))
})
}
}
// 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))
}
func TestHelpCommand(t *testing.T) { func TestHelpCommand(t *testing.T) {
t.Parallel() t.Parallel()
@@ -126,7 +190,7 @@ func TestHelpCommand(t *testing.T) {
stdout := testStdout(t, opts) stdout := testStdout(t, opts)
assert.Contains(t, stdout, cmdGenerate) assert.Contains(t, stdout, cmdGenerate)
assert.Contains(t, stdout, cmdCheck) assert.Contains(t, stdout, cmdCheck)
assert.Contains(t, stdout, cmdFetch) assert.Contains(t, stdout, "fetch")
} }
func TestGenerateCommand(t *testing.T) { func TestGenerateCommand(t *testing.T) {
@@ -679,13 +743,11 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data
// Create many small files with random names so the manifest has many // Create many small files with random names to generate a ~1MB manifest
// entries and random single-byte flips land at varied offsets. Each // Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
// manifest entry is roughly 50-60 bytes. Kept modest so the suite stays
// within its wall-clock budget under -race.
require.NoError(t, fs.MkdirAll(testDir, 0o755)) require.NoError(t, fs.MkdirAll(testDir, 0o755))
numFiles := 1500 numFiles := 20000
for range numFiles { for range numFiles {
// Generate random filename // Generate random filename
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat", filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat",
@@ -701,11 +763,11 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
exitCode := runCLI(opts) exitCode := runCLI(opts)
require.Equal(t, 0, exitCode, "generate should succeed") require.Equal(t, 0, exitCode, "generate should succeed")
// Read the valid manifest and verify it has real size. // Read the valid manifest and verify it's approximately 1MB
validManifest, err := afero.ReadFile(fs, testManifest) validManifest, err := afero.ReadFile(fs, testManifest)
require.NoError(t, err) require.NoError(t, err)
require.GreaterOrEqual(t, len(validManifest), 64*1024, require.GreaterOrEqual(t, len(validManifest), 1024*1024,
"manifest should be at least 64KB, got %d bytes", len(validManifest)) "manifest should be at least 1MB, got %d bytes", len(validManifest))
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles) t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
// First corruption: truncate the manifest // First corruption: truncate the manifest
@@ -728,8 +790,8 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
exitCode = runCLI(opts) exitCode = runCLI(opts)
require.Equal(t, 0, exitCode, "check should pass with valid manifest") require.Equal(t, 0, exitCode, "check should pass with valid manifest")
// Now do 100 random corruption iterations // Now do 500 random corruption iterations
for i := range 100 { for i := range 500 {
// Corrupt: write a random byte at a random offset // Corrupt: write a random byte at a random offset
corrupted := make([]byte, len(validManifest)) corrupted := make([]byte, len(validManifest))
copy(corrupted, validManifest) copy(corrupted, validManifest)
+9 -66
View File
@@ -36,11 +36,6 @@ const (
// traversal bit for group and other must stay set. // traversal bit for group and other must stay set.
dirPerms os.FileMode = 0o755 dirPerms os.FileMode = 0o755
// filePerms is the permission mode, before the umask, for downloaded
// files. It is the mode os.Create uses; like dirPerms, it keeps group
// and other read access.
filePerms os.FileMode = 0o666
// Bitrate unit thresholds in bits per second. // Bitrate unit thresholds in bits per second.
bpsPerGbps = 1e9 bpsPerGbps = 1e9
bpsPerMbps = 1e6 bpsPerMbps = 1e6
@@ -58,9 +53,6 @@ var (
// errPathTraversal indicates a manifest path escaping the target // errPathTraversal indicates a manifest path escaping the target
// directory. // directory.
errPathTraversal = errors.New("path traversal not allowed") errPathTraversal = errors.New("path traversal not allowed")
// errSymlinkInPath indicates a manifest path running through a
// symlink that already exists in the target directory.
errSymlinkInPath = errors.New("symlink in path not allowed")
// errSizeMismatch indicates a downloaded file with an unexpected // errSizeMismatch indicates a downloaded file with an unexpected
// size. // size.
errSizeMismatch = errors.New("size mismatch") errSizeMismatch = errors.New("size mismatch")
@@ -282,35 +274,6 @@ func sanitizePath(p string) (string, error) {
return cleaned, nil 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 := ""
for _, part := range strings.Split(p, string(filepath.Separator)) {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("failed to check %s for a symlink: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %s", errSymlinkInPath, current)
}
}
return nil
}
// resolveManifestURL takes a URL and returns the manifest URL. // resolveManifestURL takes a URL and returns the manifest URL.
// If the URL already ends with .mf, it's returned as-is. // If the URL already ends with .mf, it's returned as-is.
// Otherwise, index.mf is appended. // Otherwise, index.mf is appended.
@@ -456,12 +419,7 @@ func downloadFile(
// Create parent directories if needed // Create parent directories if needed
dir := filepath.Dir(localPath) dir := filepath.Dir(localPath)
if dir != "" && dir != "." { if dir != "" && dir != "." {
err = checkNoSymlinks(dir) err := os.MkdirAll(dir, dirPerms)
if err != nil {
return err
}
err = os.MkdirAll(dir, dirPerms)
if err != nil { if err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err) return fmt.Errorf("failed to create directory %s: %w", dir, err)
} }
@@ -489,25 +447,15 @@ func downloadFile(
totalBytes = expectedSize totalBytes = expectedSize
} }
err = checkNoSymlinks(tmpPath) // Create temp file.
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 // G304: tmpPath is derived from localPath, which sanitizePath above
// target directory as text, and checkNoSymlinks just found no symlink // constrains lexically to a relative path that does not escape the
// in it. // destination directory. That is a purely lexical guarantee: it does
out, err := os.OpenFile( //nolint:gosec // G304: see comment above // not resolve symlinks, so a pre-existing symlink inside the
tmpPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms) // destination tree can still redirect this write outside of it
// (tracked in issue #86).
out, err := os.Create(tmpPath) //nolint:gosec // G304: see comment above
if err != nil { if err != nil {
return fmt.Errorf("failed to create temp file: %w", err) return fmt.Errorf("failed to create temp file: %w", err)
} }
@@ -571,11 +519,6 @@ func finishDownload(
return err return err
} }
err = checkNoSymlinks(localPath)
if err != nil {
return err
}
// Rename temp file to final path // Rename temp file to final path
err = os.Rename(tmpPath, localPath) err = os.Rename(tmpPath, localPath)
if err != nil { if err != nil {
-83
View File
@@ -440,86 +440,3 @@ func TestFetchProgress(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, content, downloaded) 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.
//
//nolint:paralleltest // changes the process-global working directory
func TestFetchRefusesSymlinks(t *testing.T) {
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
}{
{"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"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
content := []byte("fetched")
sourceFs := afero.NewMemMapFs()
require.NoError(t, sourceFs.MkdirAll(filepath.Dir("/"+tt.entry), 0o755))
require.NoError(t, afero.WriteFile(sourceFs, "/"+tt.entry, content, 0o644))
server := httptest.NewServer(fetchTestHandler(
scanToManifest(t, sourceFs), map[string][]byte{tt.entry: content}))
defer server.Close()
outside := t.TempDir()
chdirTemp(t)
require.NoError(t, os.MkdirAll(filepath.Dir(tt.link), 0o750))
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), tt.link))
opts := testOpts([]string{testApp, cmdFetch, "-q", 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)
written, err := os.ReadDir(outside)
require.NoError(t, err)
assert.Empty(t, written, "fetch wrote outside the destination")
})
}
}
// 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
// earlier run: it must replace it and succeed, and the outside file must
// not change.
//
//nolint:paralleltest // changes the process-global working directory
func TestFetchReplacesHardLinkAtTempName(t *testing.T) {
content := []byte("fetched")
sourceFs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(sourceFs, "/"+testFileTxt, content, 0o644))
server := httptest.NewServer(fetchTestHandler(
scanToManifest(t, sourceFs), map[string][]byte{testFileTxt: content}))
defer server.Close()
outsideFile := filepath.Join(t.TempDir(), "secret.txt")
require.NoError(t, os.WriteFile(outsideFile, []byte("outside"), 0o600))
chdirTemp(t)
require.NoError(t, os.Link(outsideFile, ".file.txt.tmp"))
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
fetched, err := os.ReadFile(testFileTxt)
require.NoError(t, err)
assert.Equal(t, content, fetched)
outside, err := os.ReadFile(outsideFile) //nolint:gosec // test-controlled path
require.NoError(t, err)
assert.Equal(t, "outside", string(outside), "fetch wrote outside the destination")
}
+27 -5
View File
@@ -18,7 +18,7 @@ const (
cmdGenerate = "generate" cmdGenerate = "generate"
cmdCheck = "check" cmdCheck = "check"
cmdExport = "export" cmdExport = "export"
cmdFetch = "fetch" cmdVersion = "version"
flagProgress = "progress" flagProgress = "progress"
@@ -68,6 +68,12 @@ func (mfa *CLIApp) VersionString() string {
return mfer.Version 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() { func (mfa *CLIApp) printBanner() {
if log.GetLevel() <= log.InfoLevel { if log.GetLevel() <= log.InfoLevel {
_, _ = fmt.Fprintln(mfa.Stdout, banner) _, _ = fmt.Fprintln(mfa.Stdout, banner)
@@ -266,10 +272,10 @@ func (mfa *CLIApp) exportCommand() *cli.Command {
func (mfa *CLIApp) versionCommand() *cli.Command { func (mfa *CLIApp) versionCommand() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "version", Name: cmdVersion,
Usage: "Show version", Usage: "Show version",
Action: func(_ *cli.Context) error { Action: func(_ *cli.Context) error {
_, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString()) mfa.printVersion()
return nil return nil
}, },
@@ -301,7 +307,7 @@ func (mfa *CLIApp) listCommand() *cli.Command {
func (mfa *CLIApp) fetchCommand() *cli.Command { func (mfa *CLIApp) fetchCommand() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: cmdFetch, Name: "fetch",
Usage: "fetch manifest and referenced files", Usage: "fetch manifest and referenced files",
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
mfa.setVerbosity(c) mfa.setVerbosity(c)
@@ -325,6 +331,20 @@ func (mfa *CLIApp) run(args []string) {
log.SetOutput(mfa.Stdout, mfa.Stderr) log.SetOutput(mfa.Stdout, mfa.Stderr)
log.Init() log.Init()
// -v means verbose, not version. urfave/cli's built-in version flag
// claims -v by default, colliding with the -v verbose alias used here and
// on every subcommand; that collision makes "mfer -v --version" fail to
// parse. Verbose is the more common meaning of -v in tools that offer
// both, so verbose keeps -v and the version flag takes the capital -V.
// 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",
}
cli.VersionPrinter = func(_ *cli.Context) { mfa.printVersion() }
mfa.app = &cli.App{ mfa.app = &cli.App{
Name: mfa.appname, Name: mfa.appname,
Usage: "Manifest generator", Usage: "Manifest generator",
@@ -332,11 +352,13 @@ func (mfa *CLIApp) run(args []string) {
EnableBashCompletion: true, EnableBashCompletion: true,
Writer: mfa.Stdout, Writer: mfa.Stdout,
ErrWriter: mfa.Stderr, ErrWriter: mfa.Stderr,
Flags: commonFlags(),
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
if c.Args().Len() > 0 { if c.Args().Len() > 0 {
return fmt.Errorf("%w %q", errUnknownCommand, c.Args().First()) return fmt.Errorf("%w %q", errUnknownCommand, c.Args().First())
} }
mfa.setVerbosity(c)
mfa.printBanner() mfa.printBanner()
return cli.ShowAppHelp(c) return cli.ShowAppHelp(c)
@@ -358,6 +380,6 @@ func (mfa *CLIApp) run(args []string) {
if err != nil { if err != nil {
mfa.exitCode = 1 mfa.exitCode = 1
log.Errorf("%s", err) log.WithError(err).Debugf("exiting")
} }
} }
+41 -34
View File
@@ -112,16 +112,13 @@ func DisableStyling() {
} }
// Init initializes the logger with the CLI handler and default log level. // Init initializes the logger with the CLI handler and default log level.
//
// 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.
func Init() { func Init() {
mu.Lock() mu.RLock()
defer mu.Unlock()
log.SetHandler(acli.New(stderr)) w := stderr
mu.RUnlock()
log.SetHandler(acli.New(w))
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
} }
@@ -133,66 +130,74 @@ func isEnabled(l Level) bool {
return l >= currentLevel 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()) {
mu.RLock()
defer mu.RUnlock()
if l >= currentLevel {
fn()
}
}
// Fatalf logs a formatted message at fatal level. // Fatalf logs a formatted message at fatal level.
func Fatalf(format string, args ...any) { func Fatalf(format string, args ...any) {
emit(FatalLevel, func() { log.Fatalf(format, args...) }) if isEnabled(FatalLevel) {
log.Fatalf(format, args...)
}
} }
// Fatal logs a message at fatal level. // Fatal logs a message at fatal level.
func Fatal(arg string) { func Fatal(arg string) {
emit(FatalLevel, func() { log.Fatal(arg) }) if isEnabled(FatalLevel) {
log.Fatal(arg)
}
} }
// Errorf logs a formatted message at error level. // Errorf logs a formatted message at error level.
func Errorf(format string, args ...any) { func Errorf(format string, args ...any) {
emit(ErrorLevel, func() { log.Errorf(format, args...) }) if isEnabled(ErrorLevel) {
log.Errorf(format, args...)
}
} }
// Error logs a message at error level. // Error logs a message at error level.
func Error(arg string) { func Error(arg string) {
emit(ErrorLevel, func() { log.Error(arg) }) if isEnabled(ErrorLevel) {
log.Error(arg)
}
} }
// Warnf logs a formatted message at warn level. // Warnf logs a formatted message at warn level.
func Warnf(format string, args ...any) { func Warnf(format string, args ...any) {
emit(WarnLevel, func() { log.Warnf(format, args...) }) if isEnabled(WarnLevel) {
log.Warnf(format, args...)
}
} }
// Warn logs a message at warn level. // Warn logs a message at warn level.
func Warn(arg string) { func Warn(arg string) {
emit(WarnLevel, func() { log.Warn(arg) }) if isEnabled(WarnLevel) {
log.Warn(arg)
}
} }
// Infof logs a formatted message at info level. // Infof logs a formatted message at info level.
func Infof(format string, args ...any) { func Infof(format string, args ...any) {
emit(InfoLevel, func() { log.Infof(format, args...) }) if isEnabled(InfoLevel) {
log.Infof(format, args...)
}
} }
// Info logs a message at info level. // Info logs a message at info level.
func Info(arg string) { func Info(arg string) {
emit(InfoLevel, func() { log.Info(arg) }) if isEnabled(InfoLevel) {
log.Info(arg)
}
} }
// Verbosef logs a formatted message at verbose level. // Verbosef logs a formatted message at verbose level.
func Verbosef(format string, args ...any) { func Verbosef(format string, args ...any) {
emit(VerboseLevel, func() { log.Infof(format, args...) }) if isEnabled(VerboseLevel) {
log.Infof(format, args...)
}
} }
// Verbose logs a message at verbose level. // Verbose logs a message at verbose level.
func Verbose(arg string) { func Verbose(arg string) {
emit(VerboseLevel, func() { log.Info(arg) }) if isEnabled(VerboseLevel) {
log.Info(arg)
}
} }
// Debugf logs a formatted message at debug level with caller location. // Debugf logs a formatted message at debug level with caller location.
@@ -211,10 +216,7 @@ func Debug(arg string) {
// DebugReal logs at debug level with caller info from the specified stack depth. // DebugReal logs at debug level with caller info from the specified stack depth.
func DebugReal(arg string, cs int) { func DebugReal(arg string, cs int) {
mu.RLock() if !isEnabled(DebugLevel) {
defer mu.RUnlock()
if DebugLevel < currentLevel {
return return
} }
@@ -273,6 +275,11 @@ func GetLevel() Level {
return currentLevel return currentLevel
} }
// WithError returns a log entry with the error attached.
func WithError(e error) *log.Entry {
return log.Log.WithError(e)
}
// Progressf prints a progress message that overwrites the current line. // Progressf prints a progress message that overwrites the current line.
// Use ProgressDone() when progress is complete to move to the next line. // Use ProgressDone() when progress is complete to move to the next line.
func Progressf(format string, args ...any) { func Progressf(format string, args ...any) {
-4
View File
@@ -312,10 +312,6 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
} }
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result { func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
// entry.GetPath() is safe to join here: a manifest's entry paths are
// validated against the path invariants when it is loaded (see
// deserializeInner) or built (see Builder.AddFile), so a traversal or
// absolute path can never reach this point.
absPath := filepath.Join(string(c.basePath), entry.GetPath()) absPath := filepath.Join(string(c.basePath), entry.GetPath())
relPath := RelFilePath(entry.GetPath()) relPath := RelFilePath(entry.GetPath())
+1 -20
View File
@@ -25,7 +25,6 @@ var (
errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size") errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size")
errUUIDMismatch = errors.New("outer and inner UUID mismatch") errUUIDMismatch = errors.New("outer and inner UUID mismatch")
errInvalidFileFormat = errors.New("invalid file format") errInvalidFileFormat = errors.New("invalid file format")
errInvalidManifestPath = errors.New("manifest contains invalid path")
) )
// validateUUID checks that the byte slice is a valid UUID (16 bytes, parseable). // validateUUID checks that the byte slice is a valid UUID (16 bytes, parseable).
@@ -111,12 +110,7 @@ func (m *manifest) verifyOuterIntegrity() error {
func (m *manifest) decompressInner() ([]byte, error) { func (m *manifest) decompressInner() ([]byte, error) {
bb := bytes.NewBuffer(m.pbOuter.GetInnerMessage()) bb := bytes.NewBuffer(m.pbOuter.GetInnerMessage())
// The decoder's own limit is 64 GiB. Up to that it sets aside as many zr, err := zstd.NewReader(bb)
// bytes as a frame header claims, and it decodes a payload under
// 128 KiB in full before the LimitReader below reads any of it, so it
// needs MaxDecompressedSize as its limit too.
zr, err := zstd.NewReader(bb,
zstd.WithDecoderMaxMemory(uint64(MaxDecompressedSize)))
if err != nil { if err != nil {
return nil, fmt.Errorf("deserialize: zstd reader: %w", err) return nil, fmt.Errorf("deserialize: zstd reader: %w", err)
} }
@@ -187,19 +181,6 @@ func (m *manifest) deserializeInner() error {
return errUUIDMismatch return errUUIDMismatch
} }
// Enforce the manifest path invariants on every entry as it is loaded,
// so that no consumer of a manifest — Checker today, any restore or
// extract path tomorrow — acts on a traversal or absolute path from an
// untrusted .mf. Reject loudly on the first offender rather than
// dropping entries, which would let a hostile manifest hide files from a
// check.
for _, f := range m.pbInner.GetFiles() {
err = ValidatePath(f.GetPath())
if err != nil {
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
}
}
log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles())) log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles()))
return nil return nil
-54
View File
@@ -1,54 +0,0 @@
//nolint:testpackage // white-box tests exercise unexported internals
package mfer
import (
"bytes"
"runtime"
"testing"
)
// 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 plus MaxDecompressedSize. 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 keeps a copy of its input and at most
// MaxDecompressedSize bytes of decompressed data. Buffers grow by
// copying, so reaching those sizes allocates a few times them in
// total. Sixteen times leaves room for that, and none for a
// decoder that sets aside whatever size its input claims.
limit := 16 * (uint64(len(data)) + uint64(MaxDecompressedSize))
allocated := after.TotalAlloc - before.TotalAlloc
if allocated > limit {
t.Fatalf("allocated %d bytes for %d bytes of input, limit %d",
allocated, len(data), limit)
}
})
}
-145
View File
@@ -1,145 +0,0 @@
//nolint:testpackage // white-box tests exercise unexported internals
package mfer
import (
"bytes"
"crypto/sha256"
"fmt"
"testing"
"github.com/google/uuid"
"github.com/klauspost/compress/zstd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/proto"
)
// craftInnerBytes builds the wire bytes of an inner MFFile holding a single
// file entry whose path is exactly pathBytes. It writes the wire form by hand
// so a hostile path — including one that is not valid UTF-8 — can be embedded
// without proto.Marshal's own UTF-8 enforcement rejecting it first.
func craftInnerBytes(id uuid.UUID, pathBytes string) []byte {
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
entry = protowire.AppendString(entry, pathBytes)
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version
inner = protowire.AppendVarint(inner, uint64(MFFile_VERSION_ONE))
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
inner = protowire.AppendBytes(inner, entry)
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
inner = protowire.AppendBytes(inner, id[:])
return inner
}
// wrapInner wraps inner MFFile wire bytes in a complete, well-formed .mf
// envelope (magic prefix, zstd-compressed payload, matching hash and UUID) so
// that deserialization reaches path validation rather than failing earlier on
// an integrity check.
func wrapInner(t *testing.T, id uuid.UUID, innerData []byte) []byte {
t.Helper()
var cbuf bytes.Buffer
zw, err := zstd.NewWriter(&cbuf, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
require.NoError(t, err)
_, err = zw.Write(innerData)
require.NoError(t, err)
require.NoError(t, zw.Close())
compressed := cbuf.Bytes()
sum := sha256.Sum256(compressed)
outer := &MFFileOuter{
InnerMessage: compressed,
Size: int64(len(innerData)),
Sha256: sum[:],
Uuid: id[:],
Version: MFFileOuter_VERSION_ONE,
CompressionType: MFFileOuter_COMPRESSION_ZSTD,
}
ob, err := proto.Marshal(outer)
require.NoError(t, err)
return append([]byte(MAGIC), ob...)
}
func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
}{
{"parent traversal", "../escape"},
{"interior traversal", "a/../../escape"},
{"absolute path", "/etc/passwd"},
{"backslash path", `a\b`},
{"double slash", "a//b"},
{"empty path", ""},
{"invalid utf-8", "abc\xff"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
id := uuid.New()
data := wrapInner(t, id, craftInnerBytes(id, tt.path))
_, err := NewManifestFromReader(bytes.NewReader(data))
require.Error(t, err)
if tt.path == "abc\xff" {
// A path that is not valid UTF-8 cannot survive the proto3
// string decoder, which rejects it before path validation
// runs; the manifest is still refused at load time.
return
}
require.ErrorIs(t, err, errInvalidManifestPath)
if tt.path != "" {
// ValidatePath quotes the path with %q; assert against the
// same rendering so escaped characters (e.g. a backslash)
// still match.
assert.Contains(t, err.Error(), fmt.Sprintf("%q", tt.path),
"error must name the offending path")
}
})
}
}
func TestDeserializeValidManifestRoundTrips(t *testing.T) {
t.Parallel()
hash := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256
b := NewBuilder()
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, hash))
var buf bytes.Buffer
require.NoError(t, b.Build(&buf))
m, err := NewManifestFromReader(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
files := m.Files()
require.Len(t, files, 1)
assert.Equal(t, "dir/file.txt", files[0].GetPath())
assert.Equal(t, int64(123), files[0].GetSize())
}
// TestValidatePathRejectsInvalidUTF8 pins the ValidatePath rule that a manifest
// path must be valid UTF-8, independent of the proto decoder that also enforces
// it on the wire.
func TestValidatePathRejectsInvalidUTF8(t *testing.T) {
t.Parallel()
err := ValidatePath("abc\xff")
require.ErrorIs(t, err, errPathNotUTF8)
assert.Contains(t, err.Error(), "UTF-8")
}
-2
View File
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFGy[i\x04\xe5O\x82A\x1d\xf4\xb0\xe2z7:U\xee\xa3\xf9\xd6m\xacZ\x9b\xce\x1d\xd9/{@\x1d\xa5y[i\x04\xe5O\x82A\x1d\xf4\xb0\xe2z7:U\xee\xa3\xf9\xd6m\xacZ\x9b\xce\x1d\xd9/{@\x1d\xa5")
-2
View File
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG\x80")
-2
View File
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG\x80\xca\f\xe8\x03-----BEGIN PGP SIGNATURE-----\n\niQEzBAABCgAdFiEET1Yr+4Y/3GtRtO6IhypRF2zvI64FAmrBIXAACgkQhypRF2zv\nI67BQAf/QrpX2MjY15YGMGkjR5oIhnx/YV96aGYZyZThzb+l/R/N75iVFVkhX21d\nZhQqdCsORrodTPAXic2g2UGVXP9PhNMh7n6Wm3LsvQYjrRQGrQnqtCkut+3tUt8K\n7pt4OAnnwRSieaVImA1COmzxIrQQKNOs6UkgmAstGuPV0XZoeDiSG8TUYJ/vieCn\np5hC0FFXtzfw4NtkxSmkewE0xBxIwFCA/RfSHCGH3m5K+tRz41vMEgGbL1iEp6+V\nuBaoEc4hqCgEt+Af2pA8VHfqeu2vKiwggOpYpaILXZKVqH9+tWHL1EBv9t0vTsYE\n9D57euuR9+kOdngYNPieP1yn5dOSHg==\n=kvgL\n-----END PGP SIGNATURE-----\n\xd2\f(4F562BFB863FDC6B51B4EE88872A51176CEF23AE\xda\f\xb5\a-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQENBGrBIW8BCADESetN5EdxIe7Fafgxl99Yoo5cOexf7wJyYT0wfUYlRaxt3neR\nhir7LOfH4PZWWoDx7qghxCS4+vs7yGypl6JOm7jnJlhn4HneDa2zeIlgGW2TamyE\nua9KPWBQqkFOYmKPmzp+KnL6ncnBLR5mDkNKFyON812KVvteu6Dp/DNk4Meufe44\nWWr49LSFZa9gEbmRCoQGKby9F0H0yIi4FAc74VdQudy0+fMKcfkKjEvByMzlbBEK\n92Hq3sRFzWd3kvPliNjZTmlh5n5m9aBhMpoy3GkKy8gpDdFc6NLA9iAJe7oNMriR\nkVoa5EjQL1xCXAiAWTYA9NScFfU/574sCTxZABEBAAG0Hk1GRVIgVGVzdCBLZXkg\nPHRlc3RAbWZlci50ZXN0PokBTwQTAQoAORYhBE9WK/uGP9xrUbTuiIcqURds7yOu\nBQJqwSFvAxsvBAULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCHKlEXbO8jrjml\nCAC8wUK9wmvxq0+NZUpFyP+P29klLZYzBDaBrLPJFs0GjnG4kvfUAktWx0Ro80F7\ncjTJ4f44XjDj4glvSjbe2VaDnZl9FTfzUfG+xjD4462NgntQ4fHk/uG4F6d1ikWx\nkEoMpIn1PlSMas1jTQSGlxUr+zFwWuUbGq4n6hRxEnwLlwJlwQt/Aw1vPDYuPDE3\nOYDhJIAJyP+6e9W8ToaAG9byg/22KA1u1qxnNQqsx5Tped2VltAzdYub+yeCuNc8\nIUo5ILo/fQq3GM5sUEaHjPolv88WlDm3vcdbSbDoh5m2inDtg5zuUKJwu32UGu8l\nKoTjp4nxgQGy5WmeBzs4Hdm/\n=TCB8\n-----END PGP PUBLIC KEY BLOCK-----\n")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06!\xc2\x06 \x91\x90*\xa5>\fݐ \x87\xbeaL\xc1\x05?\x0eR\xc18\xa4eՕ\xa95\xb9KʺoZ\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\f-(\xb5/\xfd\x04\x00\x01\x01\x00\xa0\x06\x01\xaa\x06\a\n\x05a.txt\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3a[k'")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\x1f\xc2\x06 \x91\x90*\xa5>\fݐ \x87\xbeaL\xc1\x05?\x0eR\xc18\xa4eՕ\xa95\xb9KʺoZ\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\f-(\xb5/\xfd\x04\x00\x01\x01\x00\xa0\x06\x01\xaa\x06\a\n\x05a.txt\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3a[k'")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 ")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAV")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01")
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFX\xa8\x06\x01\xb0\x06\x01\xb8\x06V\xc2\x06 \xa3\xf7\x97\xa7\xf3\x87:\x90)\\ӊj\xb9\xf7\xfaTJ\x1b\xe7:\xee\xbe\"V\xe0:\x8d(Z\xd6%\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\fc(\xb5/\xfd\x04\x00\xb1\x02\x00\xa0\x06\x01\xaa\x06=\n\x05a.txt\x10\x01\x1a$\n\"\x12 ʗ\x81\x12\xca\x1b\xbd\xca\xfa\xc21\xb3\x9a#\xdcM\xa7\x86\xef\xf8\x14|Nr\xb9\x80w\x85\xaf\xeeH\xbb\xf2\x12\v\b\x80\x92\xb8Ø\xfe\xff\xff\xff\x01\xb2\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3s\xeeG\x80")
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
go test fuzz v1
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\x01\xc2\x06 \xd6aQ\xa4\x85\xc0+\xbb\xca\x11\x11\a<\x019\x97\xb3\xbb3\xd0 \xd5U\xfa!\xaeAf<N@\x9d\xca\x06\x10\x93\x85\vpu\x85\xe4\x04\xe4\x95\x1a=\xdc\x1f\x05\xa3\xba\f\x12(\xb5/\xfd\xc0\x00\x00\x00\x00\x00\x02\x00\x00\x00\v\x00\x00\x00")
+5 -15
View File
@@ -1,24 +1,14 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build; the Gitea workflow runs this on push. # script/cibuild: run the CI build. The Dockerfile runs script/check
# It builds the image with the same command as script/docker. --no-cache # (via make check), so a successful build implies all checks pass.
# because the checks the final stage depends on are RUN steps, and a # Generic: needs no adaptation. The Gitea workflow runs this on push.
# cached one is a check that did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does docker build .
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+2 -11
View File
@@ -1,8 +1,7 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN # Generic: needs no adaptation.
# steps, and a cached one is a check that did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,15 +9,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does docker build -t "$("$SCRIPT_DIR/projectname")" .
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# script/fuzz: fuzz the manifest parser for one minute. Run by hand only:
# script/test already runs the committed seed corpus as ordinary tests,
# and CI never fuzzes. An input that fails is written to
# mfer/testdata/fuzz/FuzzNewManifestFromReader/; once the parser is fixed,
# commit it there as a regression seed.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -run '^$' -fuzz '^FuzzNewManifestFromReader$' \
-fuzztime 1m -parallel 2 ./mfer
}
main "$@"
+1 -6
View File
@@ -17,12 +17,7 @@ ensure_pb() {
main() { main() {
cd "$ROOT" cd "$ROOT"
ensure_pb ensure_pb
go test -timeout 30s -race -cover ./... || go test -v --timeout 10s ./...
{
echo "--- Rerunning with -v for details ---"
go test -timeout 30s -race -v ./...
exit 1
}
} }
main "$@" main "$@"