Author SHA1 Message Date
sneak 76791d6e6c Rewrite script/test to the canonical race-enabled pattern (closes #67)
check / check (push) Failing after 0s
script/test now runs `go test -timeout 30s -race -cover ./...`, quiet on
success and rerunning verbose with `exit 1` on failure, per policy.

-race surfaced a real data race on the process-global apex/log logger:
Init reconfigures it (SetHandler/SetLevel) each CLI run while other
goroutines read it to log. internal/log now mutates the global under the
write lock and reads it under the read lock (new emit helper, DebugReal),
and drops WithError, whose returned Entry logged outside that lock.

That Entry was also the only thing printing a failed command's error to
stderr, so run() now reports it via log.Errorf (shown under -q too).

The corruption fuzz test shrinks (20000->1500 files, 500->100 iters) to
keep the suite under 20s with -race.

Model: opus-4-8
2026-09-21 08:00:35 +00:00
clawbot 6de3f1d714 Add .editorconfig, broaden .gitignore, drop dead Drone refs (closes #72)
check / check (push) Failing after 1s
Add the canonical .editorconfig from sneak/prompts verbatim. Broaden .gitignore to cover secrets (.env, .env.*, *.key, *.pem), OS files, editor files, and Go artifacts while keeping every repo-specific entry; no tracked file becomes ignored, and bin/gitrev.sh stays tracked despite the /bin/ rule. Remove the stale .drone.yml ignore entry and the DRONE_COMMIT_SHA branch from bin/gitrev.sh, left from the Gitea Actions migration; the GITREV and git-describe paths still work.

A reader may trip over .editorconfig saying four spaces for every file while Go files are tab-indented: gofmt governs Go, editorconfig does not, and the file is taken verbatim by policy.

Disclosure: the worker's cold docker build hit the 10s test timeout in the gpg signature test; this change has no Go code, and that timeout is tracked by issues 67 and 62.

Model: opus-4-8 (implementation); fable-5-1 (merge)
2026-09-21 09:56:15 +02:00
10 changed files with 226 additions and 360 deletions
+12
View File
@@ -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
View File
@@ -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
+6 -4
View File
@@ -24,9 +24,12 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
# Completed Steps # Completed Steps
- 2026-09-21: pinned CLI error messages by driving their real call sites in - 2026-09-21: rewrote `script/test` to the canonical pattern (30s timeout,
`internal/cli/errmsg_test.go`, and made the freshen mtime-presence test `-race -cover`, quiet-first with verbose-on-failure rerun) and fixed the
distinguish an absent mtime from the epoch (#87) 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
@@ -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 - 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
-5
View File
@@ -1,10 +1,5 @@
#!/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
+10 -8
View File
@@ -679,11 +679,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 +701,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 +728,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)
+133 -292
View File
@@ -2,326 +2,167 @@
package cli package cli
import ( import (
"bytes" "fmt"
"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"
) )
// These tests pin the exact rendered text of the CLI's user-visible error // errMsgCase is one pinned user-visible error message.
// messages. The messages are grepped for in CI pipelines and quoted in bug type errMsgCase struct {
// reports, so a reword is a deliberate change, never a refactoring side name string
// effect. err error
// 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"
) )
// runLocked runs fn while holding runMu, so operations that write to the func checkErrMsgCases(t *testing.T, cases []errMsgCase) {
// 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()
fs := afero.NewMemMapFs() for _, tc := range cases {
require.NoError(t, fs.MkdirAll("/d", 0o755)) t.Run(tc.name, func(t *testing.T) {
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) {
t.Parallel() t.Parallel()
assert.Equal(t, tc.want, tc.err.Error())
_, 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 // TestErrorMessagesVerbatim pins the exact rendered text of the CLI's
// manifest. The embedded fingerprint is whatever the generated key produced, // user-visible error messages.
// 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.
// //
//nolint:paralleltest // signedChecker calls t.Setenv, which bars t.Parallel // These strings are an interface: they are grepped for in CI pipelines
func TestSignerMismatchMessage(t *testing.T) { // and quoted in bug reports. The messages are assembled by wrapping
chk := signedChecker(t) // 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
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP() // precisely what happened once already. Any change to a string below is
require.NoError(t, err) // therefore a deliberate, separately stated change, never a side effect
// of a refactor.
err = verifyRequiredSigner(chk, msgFpB) func TestErrorMessagesVerbatim(t *testing.T) {
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()
set := flag.NewFlagSet("gen", flag.ContinueOnError) checkErrMsgCases(t, []errMsgCase{
require.NoError(t, set.Parse([]string{"nope"})) {
name: "check: no manifest found",
mfa := &CLIApp{Fs: afero.NewMemMapFs()} err: fmt.Errorf("%w in %s (looked for index.mf and .index.mf)",
ctx := urfcli.NewContext(nil, set, nil) errNoManifestFound, "/tmp/x"),
want: "no manifest found in /tmp/x " +
_, err := mfa.collectInputPaths(ctx.Args()) "(looked for index.mf and .index.mf)",
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),
func TestOutputFileExistsMessage(t *testing.T) { want: "invalid fingerprint: must be exactly 40 hex characters, got 8",
t.Parallel() },
{
fs := afero.NewMemMapFs() name: "check: manifest not signed",
require.NoError(t, fs.MkdirAll("/d", 0o755)) err: fmt.Errorf("%w, but signature from %s is required",
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644)) errManifestNotSigned, msgFpA),
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644)) want: "manifest is not signed, but signature from " + msgFpA +
" is required",
set := flag.NewFlagSet("gen", flag.ContinueOnError) },
set.String("output", "", "") {
set.Bool("force", false, "") name: "check: signer mismatch",
require.NoError(t, set.Parse([]string{"/d"})) err: fmt.Errorf("embedded signing key fingerprint %s %w %s",
require.NoError(t, set.Set("output", "/out.mf")) msgFpA, errSignerMismatch, msgFpB),
want: "embedded signing key fingerprint " + msgFpA +
mfa := &CLIApp{Fs: fs} " does not match required " + msgFpB,
ctx := urfcli.NewContext(nil, set, nil) },
{
// generateManifestOperation writes to the process-global logger during name: "gen: path does not exist",
// enumeration, so serialize with the other CLI runs. err: fmt.Errorf("%w: %s", errPathNotExist, "nope"),
err := runLocked(func() error { return mfa.generateManifestOperation(ctx) }) want: "path does not exist: nope",
require.ErrorIs(t, err, errOutputExists) },
assert.EqualError(t, err, {
"output file /out.mf already exists (use --force to overwrite)") name: "gen: output file exists",
} err: fmt.Errorf("output file %s %w", "index.mf", errOutputExists),
want: "output file index.mf already exists " +
func TestManifestLoaderHTTPStatusMessage(t *testing.T) { "(use --force to overwrite)",
t.Parallel() },
{
server := httptest.NewServer( name: "mfer: unknown command",
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { err: fmt.Errorf("%w %q", errUnknownCommand, "bogus"),
w.WriteHeader(http.StatusNotFound) want: `unknown command "bogus"`,
})) },
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")
}) })
} }
func TestSizeMismatchMessage(t *testing.T) { // TestFetchErrorMessagesVerbatim pins the fetch and manifest-loader
// messages; see TestErrorMessagesVerbatim for why.
func TestFetchErrorMessagesVerbatim(t *testing.T) {
t.Parallel() t.Parallel()
// finishDownload returns the size-mismatch error before it touches the checkErrMsgCases(t, []errMsgCase{
// paths, digest, or entry, so those can be zero here. {
err := finishDownload("", "", 9, 10, nil, nil, nil, nil) name: "manifest_loader: http status",
require.ErrorIs(t, err, errSizeMismatch) err: fmt.Errorf("failed to fetch %s: %w %d",
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9") "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() t.Parallel()
// A 32-byte digest that matches none of the (empty) manifest hashes. wrapped := fmt.Errorf("embedded signing key fingerprint %s %w %s",
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{}) "a", errSignerMismatch, "b")
require.ErrorIs(t, err, errHashMismatch) require.ErrorIs(t, wrapped, errSignerMismatch)
require.NotErrorIs(t, err, errSizeMismatch)
assert.EqualError(t, err, "hash mismatch") 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)
} }
+1 -4
View File
@@ -110,10 +110,7 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
const relPath = "file1.txt" const relPath = "file1.txt"
// The scanned file's mtime is the Unix epoch. If recordEntry ever misreads mtime := time.Unix(1_700_000_000, 0)
// 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 {
+1 -1
View File
@@ -357,6 +357,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
View File
@@ -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) {
+6 -1
View File
@@ -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 "$@"