//nolint:testpackage // white-box tests exercise unexported internals package cli import ( "bytes" "context" "flag" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "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. // 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 { 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) { 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. // //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) { 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") }) } func TestSizeMismatchMessage(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") } func TestHashMismatchMessage(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") }