Author SHA1 Message Date
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
6 changed files with 172 additions and 308 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
+3 -3
View File
@@ -24,9 +24,9 @@ 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: added the canonical `.editorconfig`, made `.gitignore` cover
`internal/cli/errmsg_test.go`, and made the freshen mtime-presence test secrets, OS, editor, and Go artifacts, and removed the dead Drone CI
distinguish an absent mtime from the epoch (#87) 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
-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
+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 {