Compare commits
9
Commits
23dc6dd49d
...
1d69f08434
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d69f08434 | ||
|
|
c31796998f | ||
|
|
a3749e9e9b | ||
|
|
fa97c4519c | ||
|
|
7e601929c8 | ||
|
|
c3b5fe651a | ||
|
|
0deacfc7ed | ||
|
|
7de4d6ec1c | ||
|
|
6de3f1d714 |
+62
-2
@@ -1,4 +1,64 @@
|
|||||||
|
# .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
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
+21
-2
@@ -10,5 +10,24 @@ modcache.tzst
|
|||||||
# Generated manifest files
|
# Generated manifest files
|
||||||
.index.mf
|
.index.mf
|
||||||
|
|
||||||
# Stale files
|
# Secrets
|
||||||
.drone.yml
|
.env
|
||||||
|
.env.*
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
|||||||
+20
-1
@@ -48,7 +48,26 @@ 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
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ javascript library is planned.
|
|||||||
|
|
||||||
# Build Status
|
# Build Status
|
||||||
|
|
||||||
CI runs via `script/cibuild` (`docker build .`), which executes `make check`
|
CI runs `script/cibuild`, which builds the Docker image with `--no-cache`, so
|
||||||
(formatting, linting, tests). The `main` branch must always be green.
|
the formatting, lint and test steps in the `Dockerfile` run on every build. The
|
||||||
|
`main` branch must always be green.
|
||||||
|
|
||||||
# Entrypoints
|
# Entrypoints
|
||||||
|
|
||||||
@@ -56,8 +57,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: `docker build .` (the Dockerfile runs the
|
- `script/cibuild` — CI entrypoint: builds the image with the same command as
|
||||||
checks)
|
`script/docker`, uncached, so the checks in the Dockerfile run every time
|
||||||
- `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
|
||||||
@@ -416,8 +417,8 @@ proto `go_package` option. Which is canonical?
|
|||||||
matching Scanner
|
matching Scanner
|
||||||
- [ ] Add `--deterministic` flag or make it default — omit `createdAt`, sort
|
- [ ] Add `--deterministic` flag or make it default — omit `createdAt`, sort
|
||||||
files (pending design question answer)
|
files (pending design question answer)
|
||||||
- [ ] Wire `--version` flag properly (currently only a `version` subcommand
|
- [x] Wire `--version` flag properly: `--version` and `-V` print the same line
|
||||||
exists; top-level `--version` shows urfave/cli generic output)
|
as the `version` subcommand, and `-v` means verbose
|
||||||
- [ ] Add retry logic to `fetch` — currently no retries on transient HTTP
|
- [ ] Add retry logic to `fetch` — currently no retries on transient HTTP
|
||||||
errors; needs exponential backoff
|
errors; needs exponential backoff
|
||||||
- [ ] `fetch` command uses bare `http.Get` with no timeout — needs `http.Client`
|
- [ ] `fetch` command uses bare `http.Get` with no timeout — needs `http.Client`
|
||||||
|
|||||||
@@ -24,6 +24,35 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-21: fixed the `-v` collision between `--verbose` and `--version`;
|
||||||
|
verbose owns `-v`, and version answers to `--version` and `-V` (#64)
|
||||||
|
- 2026-10-03: pinned the CLI error messages by driving the functions that emit
|
||||||
|
them in `internal/cli/errmsg_test.go`, and made the freshen mtime-presence
|
||||||
|
test distinguish an absent mtime from the epoch (#87)
|
||||||
|
- 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
|
||||||
@@ -62,7 +91,6 @@ 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
|
||||||
|
|||||||
+1
-6
@@ -1,12 +1,7 @@
|
|||||||
#!/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 --always --dirty=-dirty
|
git describe --tags --always --dirty=-dirty
|
||||||
fi
|
fi
|
||||||
|
|||||||
+76
-10
@@ -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, "fetch")
|
assert.Contains(t, stdout, cmdFetch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCommand(t *testing.T) {
|
func TestGenerateCommand(t *testing.T) {
|
||||||
@@ -679,11 +743,13 @@ 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 to generate a ~1MB manifest
|
// Create many small files with random names so the manifest has many
|
||||||
// Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
|
// entries and random single-byte flips land at varied offsets. Each
|
||||||
|
// 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 := 20000
|
numFiles := 1500
|
||||||
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",
|
||||||
@@ -699,11 +765,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's approximately 1MB
|
// Read the valid manifest and verify it has real size.
|
||||||
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), 1024*1024,
|
require.GreaterOrEqual(t, len(validManifest), 64*1024,
|
||||||
"manifest should be at least 1MB, got %d bytes", len(validManifest))
|
"manifest should be at least 64KB, 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
|
||||||
@@ -726,8 +792,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 500 random corruption iterations
|
// Now do 100 random corruption iterations
|
||||||
for i := range 500 {
|
for i := range 100 {
|
||||||
// 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)
|
||||||
|
|||||||
+320
-135
@@ -2,167 +2,352 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
urfcli "github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errMsgCase is one pinned user-visible error message.
|
// These tests pin the exact rendered text of the CLI's user-visible error
|
||||||
type errMsgCase struct {
|
// messages. The messages are grepped for in CI pipelines and quoted in bug
|
||||||
name string
|
// reports, so a reword is a deliberate change, never a refactoring side
|
||||||
err error
|
// effect.
|
||||||
want string
|
//
|
||||||
}
|
// Every case drives the real function that emits the message and asserts on
|
||||||
|
// what it returns. No production format string is restated here: a test that
|
||||||
|
// only re-rendered a copied format string would keep passing after the real
|
||||||
|
// message changed, which is exactly the regression these tests exist to
|
||||||
|
// catch.
|
||||||
|
|
||||||
|
// Full 40-hex fingerprints used where a message embeds one.
|
||||||
const (
|
const (
|
||||||
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||||
)
|
)
|
||||||
|
|
||||||
func checkErrMsgCases(t *testing.T, cases []errMsgCase) {
|
// runLocked runs fn while holding runMu, so operations that write to the
|
||||||
|
// process-global logger do not race the other CLI runs.
|
||||||
|
func runLocked(fn func() error) error {
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsignedChecker builds a Checker over a freshly scanned, unsigned manifest.
|
||||||
|
func unsignedChecker(t *testing.T) *mfer.Checker {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
for _, tc := range cases {
|
fs := afero.NewMemMapFs()
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
||||||
t.Parallel()
|
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||||
assert.Equal(t, tc.want, tc.err.Error())
|
|
||||||
})
|
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: fs})
|
||||||
}
|
require.NoError(t, s.EnumeratePath("/d", nil))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/d/index.mf", buf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
chk, err := mfer.NewChecker("/d/index.mf", "/d", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, chk.IsSigned())
|
||||||
|
|
||||||
|
return chk
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestErrorMessagesVerbatim pins the exact rendered text of the CLI's
|
func TestNoManifestFoundMessage(t *testing.T) {
|
||||||
// user-visible error messages.
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
|
||||||
|
require.ErrorIs(t, err, errNoManifestFound)
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
"no manifest found in /tmp/x (looked for index.mf and .index.mf)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("invalid fingerprint length", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := verifyRequiredSigner(unsignedChecker(t), "12345678")
|
||||||
|
require.ErrorIs(t, err, errInvalidFingerprint)
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("manifest not signed", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := verifyRequiredSigner(unsignedChecker(t), msgFpA)
|
||||||
|
require.ErrorIs(t, err, errManifestNotSigned)
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
"manifest is not signed, but signature from "+msgFpA+" is required")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
|
||||||
|
// manifest. The embedded fingerprint is whatever the generated key produced,
|
||||||
|
// so it is read back from the checker and substituted into the expected
|
||||||
|
// string; the required signer is a fixed value that cannot match it. Requires
|
||||||
|
// gpg and is skipped where it is absent, as the other signing tests are.
|
||||||
//
|
//
|
||||||
// These strings are an interface: they are grepped for in CI pipelines
|
//nolint:paralleltest // signedChecker calls t.Setenv, which bars t.Parallel
|
||||||
// and quoted in bug reports. The messages are assembled by wrapping
|
func TestSignerMismatchMessage(t *testing.T) {
|
||||||
// static sentinels, and it is easy to change what a user sees while
|
chk := signedChecker(t)
|
||||||
// only meaning to make an error matchable with errors.Is - which is
|
|
||||||
// precisely what happened once already. Any change to a string below is
|
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||||
// therefore a deliberate, separately stated change, never a side effect
|
require.NoError(t, err)
|
||||||
// of a refactor.
|
|
||||||
func TestErrorMessagesVerbatim(t *testing.T) {
|
err = verifyRequiredSigner(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 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := exec.LookPath("gpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("gpg not installed, skipping signing test")
|
||||||
|
}
|
||||||
|
|
||||||
|
gpgHome := t.TempDir()
|
||||||
|
params := "%no-protection\n" +
|
||||||
|
"Key-Type: RSA\nKey-Length: 2048\n" +
|
||||||
|
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n" +
|
||||||
|
"Expire-Date: 0\n%commit\n"
|
||||||
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||||
|
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
|
||||||
|
|
||||||
|
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
||||||
|
cmd := exec.CommandContext(context.Background(), "gpg",
|
||||||
|
"--batch", "--gen-key", paramsFile)
|
||||||
|
|
||||||
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("failed to generate test GPG key: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
|
b := mfer.NewBuilder()
|
||||||
|
b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
|
||||||
|
|
||||||
|
content := []byte("signed file")
|
||||||
|
_, err = b.AddFile("f.txt", mfer.FileSize(len(content)), mfer.ModTime{},
|
||||||
|
bytes.NewReader(content), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/index.mf", buf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
chk, err := mfer.NewChecker("/index.mf", "/", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, chk.IsSigned())
|
||||||
|
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathDoesNotExistMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
checkErrMsgCases(t, []errMsgCase{
|
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
||||||
{
|
require.NoError(t, set.Parse([]string{"nope"}))
|
||||||
name: "check: no manifest found",
|
|
||||||
err: fmt.Errorf("%w in %s (looked for index.mf and .index.mf)",
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||||
errNoManifestFound, "/tmp/x"),
|
ctx := urfcli.NewContext(nil, set, nil)
|
||||||
want: "no manifest found in /tmp/x " +
|
|
||||||
"(looked for index.mf and .index.mf)",
|
_, err := mfa.collectInputPaths(ctx.Args())
|
||||||
},
|
require.ErrorIs(t, err, errPathNotExist)
|
||||||
{
|
assert.EqualError(t, err, "path does not exist: nope")
|
||||||
name: "check: invalid fingerprint length",
|
}
|
||||||
err: fmt.Errorf("%w, got %d", errInvalidFingerprint, 8),
|
|
||||||
want: "invalid fingerprint: must be exactly 40 hex characters, got 8",
|
func TestOutputFileExistsMessage(t *testing.T) {
|
||||||
},
|
t.Parallel()
|
||||||
{
|
|
||||||
name: "check: manifest not signed",
|
fs := afero.NewMemMapFs()
|
||||||
err: fmt.Errorf("%w, but signature from %s is required",
|
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
||||||
errManifestNotSigned, msgFpA),
|
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||||
want: "manifest is not signed, but signature from " + msgFpA +
|
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644))
|
||||||
" is required",
|
|
||||||
},
|
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
||||||
{
|
set.String("output", "", "")
|
||||||
name: "check: signer mismatch",
|
set.Bool("force", false, "")
|
||||||
err: fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
require.NoError(t, set.Parse([]string{"/d"}))
|
||||||
msgFpA, errSignerMismatch, msgFpB),
|
require.NoError(t, set.Set("output", "/out.mf"))
|
||||||
want: "embedded signing key fingerprint " + msgFpA +
|
|
||||||
" does not match required " + msgFpB,
|
mfa := &CLIApp{Fs: fs}
|
||||||
},
|
ctx := urfcli.NewContext(nil, set, nil)
|
||||||
{
|
|
||||||
name: "gen: path does not exist",
|
// generateManifestOperation writes to the process-global logger during
|
||||||
err: fmt.Errorf("%w: %s", errPathNotExist, "nope"),
|
// enumeration, so serialize with the other CLI runs.
|
||||||
want: "path does not exist: nope",
|
err := runLocked(func() error { return mfa.generateManifestOperation(ctx) })
|
||||||
},
|
require.ErrorIs(t, err, errOutputExists)
|
||||||
{
|
assert.EqualError(t, err,
|
||||||
name: "gen: output file exists",
|
"output file /out.mf already exists (use --force to overwrite)")
|
||||||
err: fmt.Errorf("output file %s %w", "index.mf", errOutputExists),
|
}
|
||||||
want: "output file index.mf already exists " +
|
|
||||||
"(use --force to overwrite)",
|
// TestUnknownCommandMessage drives the root command's action. run only logs
|
||||||
},
|
// the error that action returns, so the test lets run build the app with no
|
||||||
{
|
// command given and then runs that same app on an unknown command to get the
|
||||||
name: "mfer: unknown command",
|
// error itself.
|
||||||
err: fmt.Errorf("%w %q", errUnknownCommand, "bogus"),
|
func TestUnknownCommandMessage(t *testing.T) {
|
||||||
want: `unknown command "bogus"`,
|
t.Parallel()
|
||||||
},
|
|
||||||
|
mfa := &CLIApp{
|
||||||
|
appname: testApp,
|
||||||
|
Stdout: &bytes.Buffer{},
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
Fs: afero.NewMemMapFs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// run points the process-global logger at this app's output, so
|
||||||
|
// serialize with the other CLI runs.
|
||||||
|
err := runLocked(func() error {
|
||||||
|
mfa.run([]string{testApp})
|
||||||
|
|
||||||
|
return mfa.app.Run([]string{testApp, "bogus"})
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, errUnknownCommand)
|
||||||
|
assert.EqualError(t, err, `unknown command "bogus"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestLoaderHTTPStatusMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||||
|
|
||||||
|
_, err := mfa.openManifestReader(server.URL + "/foo.mf")
|
||||||
|
require.ErrorIs(t, err, errHTTPStatus)
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
"failed to fetch "+server.URL+"/foo.mf: HTTP 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchManifestHTTPStatusMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// fetchManifestOperation logs to the process-global logger.
|
||||||
|
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
|
||||||
|
require.ErrorIs(t, err, errHTTPStatus)
|
||||||
|
assert.EqualError(t, err, "failed to fetch manifest: HTTP 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := downloadFile(context.Background(), server.URL+"/x", "x",
|
||||||
|
&mfer.MFFilePath{}, nil)
|
||||||
|
require.ErrorIs(t, err, errHTTPStatus)
|
||||||
|
assert.EqualError(t, err, "HTTP 500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLRequiredMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
||||||
|
require.NoError(t, set.Parse([]string{}))
|
||||||
|
|
||||||
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||||
|
ctx := urfcli.NewContext(nil, set, nil)
|
||||||
|
|
||||||
|
// fetchManifestOperation logs to the process-global logger.
|
||||||
|
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
|
||||||
|
require.ErrorIs(t, err, errURLRequired)
|
||||||
|
assert.EqualError(t, err, "URL argument required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizePathMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := sanitizePath("")
|
||||||
|
require.ErrorIs(t, err, errEmptyPath)
|
||||||
|
assert.EqualError(t, err, "empty path")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("absolute", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := sanitizePath("/etc/passwd")
|
||||||
|
require.ErrorIs(t, err, errAbsolutePath)
|
||||||
|
assert.EqualError(t, err, "absolute path not allowed: /etc/passwd")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("traversal", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := sanitizePath("../x")
|
||||||
|
require.ErrorIs(t, err, errPathTraversal)
|
||||||
|
assert.EqualError(t, err, "path traversal not allowed: ../x")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchErrorMessagesVerbatim pins the fetch and manifest-loader
|
func TestSizeMismatchMessage(t *testing.T) {
|
||||||
// messages; see TestErrorMessagesVerbatim for why.
|
|
||||||
func TestFetchErrorMessagesVerbatim(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
checkErrMsgCases(t, []errMsgCase{
|
// finishDownload returns the size-mismatch error before it touches the
|
||||||
{
|
// paths, digest, or entry, so those can be zero here.
|
||||||
name: "manifest_loader: http status",
|
err := finishDownload("", "", 9, 10, nil, nil, nil, nil)
|
||||||
err: fmt.Errorf("failed to fetch %s: %w %d",
|
require.ErrorIs(t, err, errSizeMismatch)
|
||||||
"https://example.com/index.mf", errHTTPStatus, 404),
|
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9")
|
||||||
want: "failed to fetch https://example.com/index.mf: HTTP 404",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: manifest http status",
|
|
||||||
err: fmt.Errorf("failed to fetch manifest: %w %d",
|
|
||||||
errHTTPStatus, 404),
|
|
||||||
want: "failed to fetch manifest: HTTP 404",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: file http status",
|
|
||||||
err: fmt.Errorf("%w %d", errHTTPStatus, 500),
|
|
||||||
want: "HTTP 500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: empty path",
|
|
||||||
err: errEmptyPath,
|
|
||||||
want: "empty path",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: absolute path",
|
|
||||||
err: fmt.Errorf("%w: %s", errAbsolutePath, "/etc/passwd"),
|
|
||||||
want: "absolute path not allowed: /etc/passwd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: path traversal",
|
|
||||||
err: fmt.Errorf("%w: %s", errPathTraversal, "../x"),
|
|
||||||
want: "path traversal not allowed: ../x",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: size mismatch",
|
|
||||||
err: fmt.Errorf("%w: expected %d bytes, got %d",
|
|
||||||
errSizeMismatch, 10, 9),
|
|
||||||
want: "size mismatch: expected 10 bytes, got 9",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: url required",
|
|
||||||
err: errURLRequired,
|
|
||||||
want: "URL argument required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fetch: hash mismatch",
|
|
||||||
err: errHashMismatch,
|
|
||||||
want: "hash mismatch",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSentinelsAreMatchable checks that the wrapped forms of the
|
func TestHashMismatchMessage(t *testing.T) {
|
||||||
// messages above remain matchable with errors.Is, which is the reason
|
|
||||||
// the sentinels exist at all.
|
|
||||||
func TestSentinelsAreMatchable(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wrapped := fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
// A 32-byte digest that matches none of the (empty) manifest hashes.
|
||||||
"a", errSignerMismatch, "b")
|
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{})
|
||||||
require.ErrorIs(t, wrapped, errSignerMismatch)
|
require.ErrorIs(t, err, errHashMismatch)
|
||||||
|
require.NotErrorIs(t, err, errSizeMismatch)
|
||||||
wrapped = fmt.Errorf("output file %s %w", "index.mf", errOutputExists)
|
assert.EqualError(t, err, "hash mismatch")
|
||||||
require.ErrorIs(t, wrapped, errOutputExists)
|
|
||||||
|
|
||||||
wrapped = fmt.Errorf("failed to fetch manifest: %w %d", errHTTPStatus, 404)
|
|
||||||
require.ErrorIs(t, wrapped, errHTTPStatus)
|
|
||||||
|
|
||||||
assert.NotErrorIs(t, errHashMismatch, errSizeMismatch)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-9
@@ -36,6 +36,11 @@ 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
|
||||||
@@ -53,6 +58,9 @@ 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")
|
||||||
@@ -274,6 +282,35 @@ 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.
|
||||||
@@ -419,7 +456,12 @@ 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 := os.MkdirAll(dir, dirPerms)
|
err = checkNoSymlinks(dir)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -447,15 +489,25 @@ func downloadFile(
|
|||||||
totalBytes = expectedSize
|
totalBytes = expectedSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temp file.
|
err = checkNoSymlinks(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 derived from localPath, which sanitizePath above
|
// G304: tmpPath is a relative path that sanitizePath keeps inside the
|
||||||
// constrains lexically to a relative path that does not escape the
|
// target directory as text, and checkNoSymlinks just found no symlink
|
||||||
// destination directory. That is a purely lexical guarantee: it does
|
// in it.
|
||||||
// not resolve symlinks, so a pre-existing symlink inside the
|
out, err := os.OpenFile( //nolint:gosec // G304: see comment above
|
||||||
// destination tree can still redirect this write outside of it
|
tmpPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms)
|
||||||
// (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)
|
||||||
}
|
}
|
||||||
@@ -519,6 +571,11 @@ 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 {
|
||||||
|
|||||||
@@ -440,3 +440,86 @@ 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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
|||||||
|
|
||||||
const relPath = "file1.txt"
|
const relPath = "file1.txt"
|
||||||
|
|
||||||
mtime := time.Unix(1_700_000_000, 0)
|
// The scanned file's mtime is the Unix epoch. If recordEntry ever misreads
|
||||||
|
// an absent manifest mtime as the epoch, the "absent" case below would
|
||||||
|
// compare equal to this and be classified unchanged, so the test fails.
|
||||||
|
mtime := time.Unix(0, 0)
|
||||||
info := stubFileInfo{size: 8, mtime: mtime}
|
info := stubFileInfo{size: 8, mtime: mtime}
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
|
|||||||
+28
-4
@@ -18,6 +18,8 @@ const (
|
|||||||
cmdGenerate = "generate"
|
cmdGenerate = "generate"
|
||||||
cmdCheck = "check"
|
cmdCheck = "check"
|
||||||
cmdExport = "export"
|
cmdExport = "export"
|
||||||
|
cmdFetch = "fetch"
|
||||||
|
cmdVersion = "version"
|
||||||
|
|
||||||
flagProgress = "progress"
|
flagProgress = "progress"
|
||||||
|
|
||||||
@@ -67,6 +69,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)
|
||||||
@@ -265,10 +273,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
|
||||||
},
|
},
|
||||||
@@ -300,7 +308,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: "fetch",
|
Name: cmdFetch,
|
||||||
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)
|
||||||
@@ -324,6 +332,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",
|
||||||
@@ -331,11 +353,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)
|
||||||
@@ -357,6 +381,6 @@ func (mfa *CLIApp) run(args []string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
mfa.exitCode = 1
|
mfa.exitCode = 1
|
||||||
|
|
||||||
log.WithError(err).Debugf("exiting")
|
log.Errorf("%s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-41
@@ -112,13 +112,16 @@ 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.RLock()
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
w := stderr
|
log.SetHandler(acli.New(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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,74 +133,66 @@ 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) {
|
||||||
if isEnabled(FatalLevel) {
|
emit(FatalLevel, func() { log.Fatalf(format, args...) })
|
||||||
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) {
|
||||||
if isEnabled(FatalLevel) {
|
emit(FatalLevel, func() { log.Fatal(arg) })
|
||||||
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) {
|
||||||
if isEnabled(ErrorLevel) {
|
emit(ErrorLevel, func() { log.Errorf(format, args...) })
|
||||||
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) {
|
||||||
if isEnabled(ErrorLevel) {
|
emit(ErrorLevel, func() { log.Error(arg) })
|
||||||
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) {
|
||||||
if isEnabled(WarnLevel) {
|
emit(WarnLevel, func() { log.Warnf(format, args...) })
|
||||||
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) {
|
||||||
if isEnabled(WarnLevel) {
|
emit(WarnLevel, func() { log.Warn(arg) })
|
||||||
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) {
|
||||||
if isEnabled(InfoLevel) {
|
emit(InfoLevel, func() { log.Infof(format, args...) })
|
||||||
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) {
|
||||||
if isEnabled(InfoLevel) {
|
emit(InfoLevel, func() { log.Info(arg) })
|
||||||
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) {
|
||||||
if isEnabled(VerboseLevel) {
|
emit(VerboseLevel, func() { log.Infof(format, args...) })
|
||||||
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) {
|
||||||
if isEnabled(VerboseLevel) {
|
emit(VerboseLevel, func() { log.Info(arg) })
|
||||||
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.
|
||||||
@@ -216,7 +211,10 @@ 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) {
|
||||||
if !isEnabled(DebugLevel) {
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
|
||||||
|
if DebugLevel < currentLevel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +273,6 @@ 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) {
|
||||||
|
|||||||
@@ -312,6 +312,10 @@ 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())
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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).
|
||||||
@@ -181,6 +182,19 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
+15
-5
@@ -1,14 +1,24 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile runs script/check
|
# script/cibuild: run the CI build; the Gitea workflow runs this on push.
|
||||||
# (via make check), so a successful build implies all checks pass.
|
# It builds the image with the same command as script/docker. --no-cache
|
||||||
# Generic: needs no adaptation. The Gitea workflow runs this on push.
|
# because the checks the final stage depends on are RUN steps, and a
|
||||||
|
# cached one is a check that did not run.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build .
|
# Own line: a failing command substitution inside an argument does
|
||||||
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
|
# empty constant. The VERSION build argument takes precedence over
|
||||||
|
# the version a build stage derives from the .git in the context.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,8 @@
|
|||||||
#!/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.
|
||||||
# Generic: needs no adaptation.
|
# --no-cache because the gate phases the final stage depends on are RUN
|
||||||
|
# steps, and a cached one is a check that did not run.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -9,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
# Own line: a failing command substitution inside an argument does
|
||||||
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
|
# empty constant. The VERSION build argument takes precedence over
|
||||||
|
# the version a build stage derives from the .git in the context.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+6
-1
@@ -17,7 +17,12 @@ ensure_pb() {
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
ensure_pb
|
ensure_pb
|
||||||
go test -v --timeout 10s ./...
|
go test -timeout 30s -race -cover ./... ||
|
||||||
|
{
|
||||||
|
echo "--- Rerunning with -v for details ---"
|
||||||
|
go test -timeout 30s -race -v ./...
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user