Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76791d6e6c | ||
|
|
6de3f1d714 |
@@ -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
|
||||
.index.mf
|
||||
|
||||
# Stale files
|
||||
.drone.yml
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Go build artifacts
|
||||
*.log
|
||||
*.out
|
||||
*.test
|
||||
|
||||
@@ -24,9 +24,12 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-21: pinned CLI error messages by driving their real call sites in
|
||||
`internal/cli/errmsg_test.go`, and made the freshen mtime-presence test
|
||||
distinguish an absent mtime from the epoch (#87)
|
||||
- 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
|
||||
`script/fmt-check` one shared prettier file set via `script/prettier`, dropped
|
||||
the `|| true` that hid prettier failures, and added a node-based Dockerfile
|
||||
@@ -65,7 +68,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
|
||||
- Pin Makefile-installed Go tools (`protoc-gen-go@v1.28.1`,
|
||||
`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
|
||||
names); name the author in the README Description first line
|
||||
- Reconcile root-level AGENTS.md with directory-hygiene policy (keep or
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
if [[ ! -z "$DRONE_COMMIT_SHA" ]]; then
|
||||
echo "${DRONE_COMMIT_SHA:0:7}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -z "$GITREV" ]]; then
|
||||
echo $GITREV
|
||||
else
|
||||
|
||||
@@ -679,11 +679,13 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data
|
||||
|
||||
// Create many small files with random names to generate a ~1MB manifest
|
||||
// Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
|
||||
// Create many small files with random names so the manifest has many
|
||||
// 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))
|
||||
|
||||
numFiles := 20000
|
||||
numFiles := 1500
|
||||
for range numFiles {
|
||||
// Generate random filename
|
||||
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat",
|
||||
@@ -699,11 +701,11 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||
exitCode := runCLI(opts)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(validManifest), 1024*1024,
|
||||
"manifest should be at least 1MB, got %d bytes", len(validManifest))
|
||||
require.GreaterOrEqual(t, len(validManifest), 64*1024,
|
||||
"manifest should be at least 64KB, got %d bytes", len(validManifest))
|
||||
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
|
||||
|
||||
// First corruption: truncate the manifest
|
||||
@@ -726,8 +728,8 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||
exitCode = runCLI(opts)
|
||||
require.Equal(t, 0, exitCode, "check should pass with valid manifest")
|
||||
|
||||
// Now do 500 random corruption iterations
|
||||
for i := range 500 {
|
||||
// Now do 100 random corruption iterations
|
||||
for i := range 100 {
|
||||
// Corrupt: write a random byte at a random offset
|
||||
corrupted := make([]byte, len(validManifest))
|
||||
copy(corrupted, validManifest)
|
||||
|
||||
+133
-292
@@ -2,326 +2,167 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
urfcli "github.com/urfave/cli/v2"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
// These tests pin the exact rendered text of the CLI's user-visible error
|
||||
// messages. The messages are grepped for in CI pipelines and quoted in bug
|
||||
// reports, so a reword is a deliberate change, never a refactoring side
|
||||
// effect.
|
||||
//
|
||||
// Every case drives the real function that emits the message and asserts on
|
||||
// what it returns. No production format string is restated here: a test that
|
||||
// only re-rendered a copied format string would keep passing after the real
|
||||
// message changed, which is exactly the regression these tests exist to
|
||||
// catch.
|
||||
// errMsgCase is one pinned user-visible error message.
|
||||
type errMsgCase struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}
|
||||
|
||||
// Full 40-hex fingerprints used where a message embeds one.
|
||||
const (
|
||||
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
)
|
||||
|
||||
// runLocked runs fn while holding runMu, so operations that write to the
|
||||
// process-global logger do not race the other CLI runs.
|
||||
func runLocked(fn func() error) error {
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// unsignedChecker builds a Checker over a freshly scanned, unsigned manifest.
|
||||
func unsignedChecker(t *testing.T) *mfer.Checker {
|
||||
func checkErrMsgCases(t *testing.T, cases []errMsgCase) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||
|
||||
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: fs})
|
||||
require.NoError(t, s.EnumeratePath("/d", nil))
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||
require.NoError(t, afero.WriteFile(fs, "/d/index.mf", buf.Bytes(), 0o644))
|
||||
|
||||
chk, err := mfer.NewChecker("/d/index.mf", "/d", fs)
|
||||
require.NoError(t, err)
|
||||
require.False(t, chk.IsSigned())
|
||||
|
||||
return chk
|
||||
}
|
||||
|
||||
func TestNoManifestFoundMessage(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
|
||||
require.ErrorIs(t, err, errNoManifestFound)
|
||||
assert.EqualError(t, err,
|
||||
"no manifest found in /tmp/x (looked for index.mf 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")
|
||||
assert.Equal(t, tc.want, tc.err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TestErrorMessagesVerbatim pins the exact rendered text of the CLI's
|
||||
// user-visible error messages.
|
||||
//
|
||||
//nolint:paralleltest // signedChecker calls t.Setenv, which bars t.Parallel
|
||||
func TestSignerMismatchMessage(t *testing.T) {
|
||||
chk := signedChecker(t)
|
||||
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||
require.NoError(t, err)
|
||||
|
||||
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) {
|
||||
// These strings are an interface: they are grepped for in CI pipelines
|
||||
// and quoted in bug reports. The messages are assembled by wrapping
|
||||
// static sentinels, and it is easy to change what a user sees while
|
||||
// only meaning to make an error matchable with errors.Is - which is
|
||||
// precisely what happened once already. Any change to a string below is
|
||||
// therefore a deliberate, separately stated change, never a side effect
|
||||
// of a refactor.
|
||||
func TestErrorMessagesVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
||||
require.NoError(t, set.Parse([]string{"nope"}))
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||
ctx := urfcli.NewContext(nil, set, nil)
|
||||
|
||||
_, err := mfa.collectInputPaths(ctx.Args())
|
||||
require.ErrorIs(t, err, errPathNotExist)
|
||||
assert.EqualError(t, err, "path does not exist: nope")
|
||||
}
|
||||
|
||||
func TestOutputFileExistsMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644))
|
||||
|
||||
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
||||
set.String("output", "", "")
|
||||
set.Bool("force", false, "")
|
||||
require.NoError(t, set.Parse([]string{"/d"}))
|
||||
require.NoError(t, set.Set("output", "/out.mf"))
|
||||
|
||||
mfa := &CLIApp{Fs: fs}
|
||||
ctx := urfcli.NewContext(nil, set, nil)
|
||||
|
||||
// generateManifestOperation writes to the process-global logger during
|
||||
// enumeration, so serialize with the other CLI runs.
|
||||
err := runLocked(func() error { return mfa.generateManifestOperation(ctx) })
|
||||
require.ErrorIs(t, err, errOutputExists)
|
||||
assert.EqualError(t, err,
|
||||
"output file /out.mf already exists (use --force to overwrite)")
|
||||
}
|
||||
|
||||
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")
|
||||
checkErrMsgCases(t, []errMsgCase{
|
||||
{
|
||||
name: "check: no manifest found",
|
||||
err: fmt.Errorf("%w in %s (looked for index.mf and .index.mf)",
|
||||
errNoManifestFound, "/tmp/x"),
|
||||
want: "no manifest found in /tmp/x " +
|
||||
"(looked for index.mf and .index.mf)",
|
||||
},
|
||||
{
|
||||
name: "check: invalid fingerprint length",
|
||||
err: fmt.Errorf("%w, got %d", errInvalidFingerprint, 8),
|
||||
want: "invalid fingerprint: must be exactly 40 hex characters, got 8",
|
||||
},
|
||||
{
|
||||
name: "check: manifest not signed",
|
||||
err: fmt.Errorf("%w, but signature from %s is required",
|
||||
errManifestNotSigned, msgFpA),
|
||||
want: "manifest is not signed, but signature from " + msgFpA +
|
||||
" is required",
|
||||
},
|
||||
{
|
||||
name: "check: signer mismatch",
|
||||
err: fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
msgFpA, errSignerMismatch, msgFpB),
|
||||
want: "embedded signing key fingerprint " + msgFpA +
|
||||
" does not match required " + msgFpB,
|
||||
},
|
||||
{
|
||||
name: "gen: path does not exist",
|
||||
err: fmt.Errorf("%w: %s", errPathNotExist, "nope"),
|
||||
want: "path does not exist: nope",
|
||||
},
|
||||
{
|
||||
name: "gen: output file exists",
|
||||
err: fmt.Errorf("output file %s %w", "index.mf", errOutputExists),
|
||||
want: "output file index.mf already exists " +
|
||||
"(use --force to overwrite)",
|
||||
},
|
||||
{
|
||||
name: "mfer: unknown command",
|
||||
err: fmt.Errorf("%w %q", errUnknownCommand, "bogus"),
|
||||
want: `unknown command "bogus"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSizeMismatchMessage(t *testing.T) {
|
||||
// TestFetchErrorMessagesVerbatim pins the fetch and manifest-loader
|
||||
// messages; see TestErrorMessagesVerbatim for why.
|
||||
func TestFetchErrorMessagesVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// finishDownload returns the size-mismatch error before it touches the
|
||||
// paths, digest, or entry, so those can be zero here.
|
||||
err := finishDownload("", "", 9, 10, nil, nil, nil, nil)
|
||||
require.ErrorIs(t, err, errSizeMismatch)
|
||||
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9")
|
||||
checkErrMsgCases(t, []errMsgCase{
|
||||
{
|
||||
name: "manifest_loader: http status",
|
||||
err: fmt.Errorf("failed to fetch %s: %w %d",
|
||||
"https://example.com/index.mf", errHTTPStatus, 404),
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestHashMismatchMessage(t *testing.T) {
|
||||
// TestSentinelsAreMatchable checks that the wrapped forms of the
|
||||
// messages above remain matchable with errors.Is, which is the reason
|
||||
// the sentinels exist at all.
|
||||
func TestSentinelsAreMatchable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 32-byte digest that matches none of the (empty) manifest hashes.
|
||||
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{})
|
||||
require.ErrorIs(t, err, errHashMismatch)
|
||||
require.NotErrorIs(t, err, errSizeMismatch)
|
||||
assert.EqualError(t, err, "hash mismatch")
|
||||
wrapped := fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
"a", errSignerMismatch, "b")
|
||||
require.ErrorIs(t, wrapped, errSignerMismatch)
|
||||
|
||||
wrapped = fmt.Errorf("output file %s %w", "index.mf", errOutputExists)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -110,10 +110,7 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
||||
|
||||
const relPath = "file1.txt"
|
||||
|
||||
// The scanned file's mtime is the Unix epoch. If recordEntry ever misreads
|
||||
// an absent manifest mtime as the epoch, the "absent" case below would
|
||||
// compare equal to this and be classified unchanged, so the test fails.
|
||||
mtime := time.Unix(0, 0)
|
||||
mtime := time.Unix(1_700_000_000, 0)
|
||||
info := stubFileInfo{size: 8, mtime: mtime}
|
||||
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -357,6 +357,6 @@ func (mfa *CLIApp) run(args []string) {
|
||||
if err != nil {
|
||||
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.
|
||||
//
|
||||
// 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() {
|
||||
mu.RLock()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
w := stderr
|
||||
|
||||
mu.RUnlock()
|
||||
log.SetHandler(acli.New(w))
|
||||
log.SetHandler(acli.New(stderr))
|
||||
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
|
||||
}
|
||||
|
||||
@@ -130,74 +133,66 @@ func isEnabled(l Level) bool {
|
||||
return l >= currentLevel
|
||||
}
|
||||
|
||||
// emit calls fn while holding the read lock if messages at level l are
|
||||
// enabled. Holding the read lock across the apex/log call keeps the global
|
||||
// logger from being read while Init reconfigures it under the write lock.
|
||||
func emit(l Level, fn func()) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
|
||||
if l >= currentLevel {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted message at fatal level.
|
||||
func Fatalf(format string, args ...any) {
|
||||
if isEnabled(FatalLevel) {
|
||||
log.Fatalf(format, args...)
|
||||
}
|
||||
emit(FatalLevel, func() { log.Fatalf(format, args...) })
|
||||
}
|
||||
|
||||
// Fatal logs a message at fatal level.
|
||||
func Fatal(arg string) {
|
||||
if isEnabled(FatalLevel) {
|
||||
log.Fatal(arg)
|
||||
}
|
||||
emit(FatalLevel, func() { log.Fatal(arg) })
|
||||
}
|
||||
|
||||
// Errorf logs a formatted message at error level.
|
||||
func Errorf(format string, args ...any) {
|
||||
if isEnabled(ErrorLevel) {
|
||||
log.Errorf(format, args...)
|
||||
}
|
||||
emit(ErrorLevel, func() { log.Errorf(format, args...) })
|
||||
}
|
||||
|
||||
// Error logs a message at error level.
|
||||
func Error(arg string) {
|
||||
if isEnabled(ErrorLevel) {
|
||||
log.Error(arg)
|
||||
}
|
||||
emit(ErrorLevel, func() { log.Error(arg) })
|
||||
}
|
||||
|
||||
// Warnf logs a formatted message at warn level.
|
||||
func Warnf(format string, args ...any) {
|
||||
if isEnabled(WarnLevel) {
|
||||
log.Warnf(format, args...)
|
||||
}
|
||||
emit(WarnLevel, func() { log.Warnf(format, args...) })
|
||||
}
|
||||
|
||||
// Warn logs a message at warn level.
|
||||
func Warn(arg string) {
|
||||
if isEnabled(WarnLevel) {
|
||||
log.Warn(arg)
|
||||
}
|
||||
emit(WarnLevel, func() { log.Warn(arg) })
|
||||
}
|
||||
|
||||
// Infof logs a formatted message at info level.
|
||||
func Infof(format string, args ...any) {
|
||||
if isEnabled(InfoLevel) {
|
||||
log.Infof(format, args...)
|
||||
}
|
||||
emit(InfoLevel, func() { log.Infof(format, args...) })
|
||||
}
|
||||
|
||||
// Info logs a message at info level.
|
||||
func Info(arg string) {
|
||||
if isEnabled(InfoLevel) {
|
||||
log.Info(arg)
|
||||
}
|
||||
emit(InfoLevel, func() { log.Info(arg) })
|
||||
}
|
||||
|
||||
// Verbosef logs a formatted message at verbose level.
|
||||
func Verbosef(format string, args ...any) {
|
||||
if isEnabled(VerboseLevel) {
|
||||
log.Infof(format, args...)
|
||||
}
|
||||
emit(VerboseLevel, func() { log.Infof(format, args...) })
|
||||
}
|
||||
|
||||
// Verbose logs a message at verbose level.
|
||||
func Verbose(arg string) {
|
||||
if isEnabled(VerboseLevel) {
|
||||
log.Info(arg)
|
||||
}
|
||||
emit(VerboseLevel, func() { log.Info(arg) })
|
||||
}
|
||||
|
||||
// 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.
|
||||
func DebugReal(arg string, cs int) {
|
||||
if !isEnabled(DebugLevel) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
|
||||
if DebugLevel < currentLevel {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -275,11 +273,6 @@ func GetLevel() Level {
|
||||
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.
|
||||
// Use ProgressDone() when progress is complete to move to the next line.
|
||||
func Progressf(format string, args ...any) {
|
||||
|
||||
+6
-1
@@ -17,7 +17,12 @@ ensure_pb() {
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user