//nolint:testpackage // white-box tests exercise unexported internals package cli import ( "bytes" "context" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "slices" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" urfcli "github.com/urfave/cli/v3" "google.golang.org/protobuf/proto" "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(&mfer.CheckerOptions{ ManifestPath: "/d/index.mf", BasePath: "/d", Fs: 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)") } 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 signing key's 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 // signedManifest calls t.Setenv, which bars t.Parallel func TestSignerMismatchMessage(t *testing.T) { chk := signedChecker(t, signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")})) err := verifyRequiredSigner(chk, msgFpB) require.ErrorIs(t, err, errSignerMismatch) assert.EqualError(t, err, "embedded signing key fingerprint "+string(chk.Signer())+ " does not match required "+msgFpB) } // signedManifest returns a manifest of files signed by a throwaway GPG key // generated in a temporary GNUPGHOME, which it leaves set for the rest of // the test. func signedManifest(t *testing.T, files map[string][]byte) []byte { 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")}) for path, content := range files { _, err = b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)), mfer.ModTime{}, 0, bytes.NewReader(content), nil) require.NoError(t, err) } var buf bytes.Buffer require.NoError(t, b.Build(context.Background(), &buf)) return buf.Bytes() } // signedChecker builds a Checker over manifest, a signed manifest. func signedChecker(t *testing.T, manifest []byte) *mfer.Checker { t.Helper() fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644)) chk, err := mfer.NewChecker(&mfer.CheckerOptions{ ManifestPath: "/index.mf", BasePath: "/", Fs: fs, }) require.NoError(t, err) require.True(t, chk.IsSigned()) return chk } // manifestSignedByAnotherKey returns a manifest of files and the // fingerprint of a throwaway key, the required key, that did not sign it. // The manifest is signed by a second throwaway key; its embedded public key // block holds the required key followed by the second key, and its signer // field names the required key. func manifestSignedByAnotherKey( t *testing.T, files map[string][]byte, ) ([]byte, string) { t.Helper() required := new(mfer.MFFileOuter) require.NoError(t, proto.Unmarshal( signedManifest(t, files)[len(mfer.MAGIC):], required)) outer := new(mfer.MFFileOuter) require.NoError(t, proto.Unmarshal( signedManifest(t, files)[len(mfer.MAGIC):], outer)) outer.SigningPubKey = slices.Concat( required.GetSigningPubKey(), outer.GetSigningPubKey()) outer.Signer = required.GetSigner() data, err := proto.Marshal(outer) require.NoError(t, err) return append([]byte(mfer.MAGIC), data...), string(required.GetSigner()) } func TestPathDoesNotExistMessage(t *testing.T) { t.Parallel() mfa := &CLIApp{Fs: afero.NewMemMapFs()} cmd := &urfcli.Command{ Name: cmdGenerate, Action: func(_ context.Context, c *urfcli.Command) error { _, err := mfa.collectInputPaths(c.Args()) return err }, } err := cmd.Run(context.Background(), []string{cmdGenerate, "nope"}) 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)) mfa := &CLIApp{Fs: fs} cmd := &urfcli.Command{ Name: cmdGenerate, Flags: []urfcli.Flag{ &urfcli.StringFlag{Name: "output"}, &urfcli.BoolFlag{Name: "force"}, }, Action: mfa.generateManifestOperation, } // generateManifestOperation writes to the process-global logger during // enumeration, so serialize with the other CLI runs. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdGenerate, "--output", "/out.mf", "/d"}) }) require.ErrorIs(t, err, errOutputExists) assert.EqualError(t, err, "output file /out.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 // error itself. func TestUnknownCommandMessage(t *testing.T) { 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(context.Background(), []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(context.Background(), server.URL+"/foo.mf") require.ErrorIs(t, err, errHTTPStatus) assert.EqualError(t, err, "download manifest "+server.URL+"/foo.mf: unexpected HTTP status 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() mfa := &CLIApp{Fs: afero.NewMemMapFs()} cmd := mfa.fetchCommand() cmd.Action = mfa.fetchManifestOperation // fetchManifestOperation logs to the process-global logger. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdFetch, server.URL}) }) require.ErrorIs(t, err, errHTTPStatus) assert.EqualError(t, err, "download manifest: unexpected HTTP status 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() // downloadFile logs each retry of the 500 to the process-global logger. err := runLocked(func() error { return downloadFile(context.Background(), testClient(), server.URL+"/x", ".", "x", &mfer.MFFilePath{}, nil) }) require.ErrorIs(t, err, errHTTPStatus) assert.EqualError(t, err, "unexpected HTTP status 500") } // TestCheckCorruptManifestMessage runs check on a file that is not a // manifest. func TestCheckCorruptManifestMessage(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644)) mfa := &CLIApp{Fs: fs} cmd := mfa.checkCommand() cmd.Action = mfa.checkManifestOperation // checkManifestOperation logs to the process-global logger. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdCheck, "/bad.mf"}) }) assert.EqualError(t, err, "load manifest: invalid file format") } // TestListMissingManifestMessage runs list on a manifest file that does not // exist. func TestListMissingManifestMessage(t *testing.T) { t.Parallel() mfa := &CLIApp{Fs: afero.NewMemMapFs()} cmd := mfa.listCommand() // listManifestOperation sets the process-global log level. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdList, "/nope.mf"}) }) require.ErrorIs(t, err, os.ErrNotExist) assert.EqualError(t, err, "open /nope.mf: file does not exist") } // TestFetchHashMismatchMessage runs fetch against a server that sends a // listed file with other content of the same size. func TestFetchHashMismatchMessage(t *testing.T) { t.Parallel() manifest := builtManifest(t, map[string][]byte{testFileTxt: []byte("listed")}) server := httptest.NewServer(fetchTestHandler(manifest, map[string][]byte{testFileTxt: []byte("served")})) defer server.Close() mfa := &CLIApp{Fs: afero.NewMemMapFs(), maxManifestSize: mfer.MaxManifestSize} cmd := mfa.fetchCommand() cmd.Action = mfa.fetchManifestOperation // fetchManifestOperation logs to the process-global logger. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdFetch, "--" + flagDest, t.TempDir(), server.URL}) }) require.ErrorIs(t, err, errHashMismatch) assert.EqualError(t, err, "download "+testFileTxt+": hash mismatch") } // TestFreshenBackslashPathMessage runs freshen on a tree that has gained a // file whose name holds a backslash, which a manifest path may not contain. func TestFreshenBackslashPathMessage(t *testing.T) { t.Parallel() fs := afero.NewOsFs() root, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "content"}) writeTestFile(t, fs, filepath.Join(root, `a\b.txt`), "new") mfa := &CLIApp{Fs: fs} cmd := mfa.freshenCommand() cmd.Action = mfa.freshenManifestOperation // freshenManifestOperation logs to the process-global logger. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdFreshen, testFlagBase, root, manifestPath}) }) assert.EqualError(t, err, `path "a\\b.txt" contains backslash; use forward slashes only`) } // TestFreshenReadErrorMessage has freshen hash a directory as though it // were a file, so reading it fails. func TestFreshenReadErrorMessage(t *testing.T) { t.Parallel() root := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(root, "sub"), 0o750)) hasher := &freshenHasher{ fs: afero.NewOsFs(), absBase: root, builder: mfer.NewBuilder(), } err := hasher.processEntry(&freshenEntry{path: "sub", needsHash: true}) assert.EqualError(t, err, "read "+filepath.Join(root, "sub")+": is a directory") } func TestURLRequiredMessage(t *testing.T) { t.Parallel() mfa := &CLIApp{Fs: afero.NewMemMapFs()} cmd := &urfcli.Command{Name: cmdFetch, Action: mfa.fetchManifestOperation} // fetchManifestOperation logs to the process-global logger. err := runLocked(func() error { return cmd.Run(context.Background(), []string{cmdFetch}) }) 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") }