//nolint:testpackage // white-box tests exercise unexported internals package cli import ( "bufio" "io" "net/http" "net/http/httptest" "path/filepath" "slices" "strings" "sync/atomic" "testing" "time" "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/creack/pty" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testFlagSignKey = "--sign-key" testKeyFile = "/key.asc" ) // TestGenAndFreshenSignWithKeyFile runs gen, then freshen after a file is // added, with --sign-key naming a key file: one key with no passphrase and // one protected by the passphrase in MFER_SIGN_KEY_PASSPHRASE. check // --require-signature must accept each manifest as signed by that key. // freshen leaves its manifest out of the listing only on the real // filesystem, so the test uses that. func TestGenAndFreshenSignWithKeyFile(t *testing.T) { for name, passphrase := range map[string][]byte{ "unprotected": nil, "protected": []byte("passphrase"), } { t.Run(name, func(t *testing.T) { t.Setenv(envSignKeyPassphrase, string(passphrase)) secretKey, fingerprint := testSecretKey(t, passphrase, nil) fs := afero.NewOsFs() keyFile := filepath.Join(t.TempDir(), "key.asc") root := t.TempDir() manifestPath := filepath.Join(root, defaultManifestName) require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600)) writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") opts := testOpts([]string{ testApp, cmdGenerate, "-q", testFlagSignKey, keyFile, "-o", manifestPath, root, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) check := []string{ testApp, cmdCheck, "-q", "--" + flagRequireSignature, fingerprint, manifestPath, } opts = testOpts(check, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added") opts = testOpts([]string{ testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) opts = testOpts(check, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) assert.Len(t, manifestFiles(t, fs, manifestPath), 2) }) } } // TestFreshenSignedManifestNeedsSignKey runs freshen on a signed manifest // after a file is added. Without --sign-key it must fail, naming the // signer and --sign-key, and leave the manifest as it was; with --sign-key // it must rewrite the manifest, signed. func TestFreshenSignedManifestNeedsSignKey(t *testing.T) { t.Setenv(envSignKeyPassphrase, "") secretKey, fingerprint := testSecretKey(t, nil, nil) fs := afero.NewOsFs() keyFile := filepath.Join(t.TempDir(), "key.asc") root := t.TempDir() manifestPath := filepath.Join(root, defaultManifestName) require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600)) writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") opts := testOpts([]string{ testApp, cmdGenerate, "-q", testFlagSignKey, keyFile, "-o", manifestPath, root, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) before, err := afero.ReadFile(fs, manifestPath) require.NoError(t, err) writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added") opts = testOpts([]string{testApp, cmdFreshen, "-q", manifestPath}, fs) assert.Equal(t, 1, runCLI(opts)) assert.Contains(t, testStderr(t, opts), fingerprint) assert.Contains(t, testStderr(t, opts), testFlagSignKey) after, err := afero.ReadFile(fs, manifestPath) require.NoError(t, err) assert.Equal(t, before, after) opts = testOpts([]string{ testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) opts = testOpts([]string{ testApp, cmdCheck, "-q", "--" + flagRequireSignature, fingerprint, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) assert.Len(t, manifestFiles(t, fs, manifestPath), 2) } // TestFreshenSignsUnchangedTree runs freshen with --sign-key on a tree that // has not changed since gen made its unsigned manifest. freshen must // rewrite the manifest signed by that key. Run again with the same key it // must leave the manifest as it is; with another key it must rewrite it // signed by that one. func TestFreshenSignsUnchangedTree(t *testing.T) { t.Setenv(envSignKeyPassphrase, "") fs := afero.NewOsFs() _, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "hello"}) newKeyFile := func() (string, string) { secretKey, fingerprint := testSecretKey(t, nil, nil) keyFile := filepath.Join(t.TempDir(), "key.asc") require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600)) return keyFile, fingerprint } freshenAndCheckSignedBy := func(keyFile, fingerprint string) { opts := testOpts([]string{ testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) opts = testOpts([]string{ testApp, cmdCheck, "-q", "--" + flagRequireSignature, fingerprint, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), testStderr(t, opts)) } keyFile, fingerprint := newKeyFile() freshenAndCheckSignedBy(keyFile, fingerprint) before, err := afero.ReadFile(fs, manifestPath) require.NoError(t, err) freshenAndCheckSignedBy(keyFile, fingerprint) after, err := afero.ReadFile(fs, manifestPath) require.NoError(t, err) assert.Equal(t, before, after, "manifest already signed by the key was rewritten") freshenAndCheckSignedBy(newKeyFile()) } // TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key, // with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must // fail, naming the variable, and write no manifest. func TestSignWithProtectedKeyNeedsPassphrase(t *testing.T) { t.Setenv(envSignKeyPassphrase, "") secretKey, _ := testSecretKey(t, []byte("secret"), nil) fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600)) require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") opts := testOpts([]string{ testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile, "-o", testMF, testDir, }, fs) assert.Equal(t, 1, runCLI(opts)) assert.Contains(t, testStderr(t, opts), "signing key is protected: set MFER_SIGN_KEY_PASSPHRASE to its passphrase") exists, err := afero.Exists(fs, testMF) require.NoError(t, err) assert.False(t, exists) } // TestSignWithKeyThatCannotSignFailsFirst runs gen on a directory and // freshen on a manifest, neither of which exists, with keys that cannot // sign: a protected key with a wrong MFER_SIGN_KEY_PASSPHRASE, a key that // expired in 2020, and a version 6 key. Each run must fail on the key: it // checks the key before it reads any file, so a missing file goes // unnoticed. func TestSignWithKeyThatCannotSignFailsFirst(t *testing.T) { t.Setenv(envSignKeyPassphrase, "wrong") wrongPassphrase, _ := testSecretKey(t, []byte("right"), nil) made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) expired, _ := testSecretKey(t, nil, &packet.Config{ Algorithm: packet.PubKeyAlgoEdDSA, Time: func() time.Time { return made }, KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()), }) version6, _ := testSecretKey(t, nil, &packet.Config{ Algorithm: packet.PubKeyAlgoEd25519, V6Keys: true, }) for want, secretKey := range map[string][]byte{ "unlock signing key": wrongPassphrase, "signing key cannot sign": expired, "signing key must be an OpenPGP version 4 key": version6, } { fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600)) for _, args := range [][]string{ { testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile, "-o", testMF, "/missing", }, {testApp, cmdFreshen, "-q", testFlagSignKey, testKeyFile, "/missing.mf"}, } { opts := testOpts(args, fs) assert.Equal(t, 1, runCLI(opts), args[1], want) assert.Contains(t, testStderr(t, opts), testKeyFile+": "+want, args[1]) } } } // TestGenAsksForPassphraseOnTerminal runs gen with a protected key, no // MFER_SIGN_KEY_PASSPHRASE, and a terminal as stdin and stderr. gen must // ask for the passphrase on stderr, and sign with what is typed after the // prompt. func TestGenAsksForPassphraseOnTerminal(t *testing.T) { t.Setenv(envSignKeyPassphrase, "") secretKey, fingerprint := testSecretKey(t, []byte("passphrase"), nil) fs := afero.NewOsFs() keyFile := filepath.Join(t.TempDir(), "key.asc") root := t.TempDir() manifestPath := filepath.Join(root, defaultManifestName) require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600)) writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") terminal, tty, err := pty.Open() require.NoError(t, err) t.Cleanup(func() { _ = terminal.Close() }) opts := testOpts([]string{ testApp, cmdGenerate, "-q", testFlagSignKey, keyFile, "-o", manifestPath, root, }, fs) opts.Stdin = tty opts.Stderr = tty exitCode := make(chan int, 1) go func() { exitCode <- runCLI(opts) // Once gen has ended, reading the terminal fails instead of // waiting for a prompt that will not come. _ = tty.Close() }() prompt := "Passphrase for " + keyFile + ": " output := bufio.NewReader(terminal) written := "" for !strings.HasSuffix(written, prompt) { b, err := output.ReadByte() require.NoError(t, err, "gen wrote %q and no prompt", written) written += string(b) } _, err = terminal.WriteString("passphrase\n") require.NoError(t, err) code := <-exitCode rest, _ := io.ReadAll(output) require.Equal(t, 0, code, "gen wrote %q", rest) check := testOpts([]string{ testApp, cmdCheck, "-q", "--" + flagRequireSignature, fingerprint, manifestPath, }, fs) require.Equal(t, 0, runCLI(check), testStderr(t, check)) } // assertEmptySignKeyRefused runs gen and freshen, each with flags, on a // directory holding a file and an unsigned manifest. Each must fail, // naming --sign-key, and leave the manifest as it was. func assertEmptySignKeyRefused(t *testing.T, flags ...string) { t.Helper() manifest := manifestOf(t, map[string][]byte{testFileTxt: []byte("listed")}) for command, rest := range map[string][]string{ cmdGenerate: {"-q", "-f", "-o", testMF, testDir}, cmdFreshen: {"-q", testMF}, } { fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644)) opts := testOpts(slices.Concat([]string{testApp, command}, flags, rest), fs) assert.Equal(t, 1, runCLI(opts), command) assert.Contains(t, testStderr(t, opts), errEmptySignKey.Error(), command) content, err := afero.ReadFile(fs, testMF) require.NoError(t, err) assert.Equal(t, manifest, content, command) } } // TestGenAndFreshenRefuseEmptySignKey runs gen and freshen with --sign-key // given an empty value, as an unset shell variable gives it. func TestGenAndFreshenRefuseEmptySignKey(t *testing.T) { t.Parallel() assertEmptySignKeyRefused(t, testFlagSignKey, "") } // TestGenAndFreshenRefuseEmptySignKeyFromEnvironment runs gen and freshen // with MFER_SIGN_KEY set to an empty string. func TestGenAndFreshenRefuseEmptySignKeyFromEnvironment(t *testing.T) { t.Setenv("MFER_SIGN_KEY", "") assertEmptySignKeyRefused(t) } // assertEmptyFingerprintRefused runs check on an unsigned manifest, and // fetch of that manifest from a server, each with flags. Each must refuse // the empty fingerprint as malformed, and fetch must request nothing. func assertEmptyFingerprintRefused(t *testing.T, flags ...string) { t.Helper() const want = "invalid fingerprint: must be exactly 40 hex characters, got 0" files := map[string][]byte{testFileTxt: []byte("listed")} manifest := manifestOf(t, files) fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, filepath.Join(testDir, testFileTxt), "listed") require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644)) opts := testOpts(slices.Concat( []string{testApp, cmdCheck, "-q"}, flags, []string{testMF}, ), fs) assert.Equal(t, 1, runCLI(opts), cmdCheck) assert.Contains(t, testStderr(t, opts), want, cmdCheck) var requests atomic.Int32 tree := fetchTestHandler(manifest, files) server := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) tree.ServeHTTP(w, r) })) defer server.Close() opts = testOpts(slices.Concat( []string{testApp, cmdFetch, "-q", "--" + flagDest, t.TempDir()}, flags, []string{server.URL}, ), afero.NewOsFs()) assert.Equal(t, 1, runCLI(opts), cmdFetch) assert.Contains(t, testStderr(t, opts), want, cmdFetch) assert.Zero(t, requests.Load(), "fetch made a request") } // TestCheckAndFetchRefuseEmptyRequireSignature runs check and fetch with // --require-signature given an empty value, as an unset shell variable // gives it. func TestCheckAndFetchRefuseEmptyRequireSignature(t *testing.T) { t.Parallel() assertEmptyFingerprintRefused(t, "--"+flagRequireSignature, "") } // TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment runs check // and fetch with MFER_REQUIRE_SIGNATURE set to an empty string. func TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment(t *testing.T) { t.Setenv("MFER_REQUIRE_SIGNATURE", "") assertEmptyFingerprintRefused(t) }