check / check (push) Waiting to run
mfer ran the gpg binary to sign, export keys and verify, so signing and loading signed manifests failed wherever gpg is missing. It now uses github.com/ProtonMail/go-crypto/openpgp. --sign-key and MFER_SIGN_KEY name a file holding one version 4 OpenPGP secret key; a protected key's passphrase comes from MFER_SIGN_KEY_PASSPHRASE or a terminal prompt. gen and freshen check that the key can sign before they read any file. Verification keeps the rules of the --require-signature fix: one primary key in the embedded block, counted from its packets, exactly one signature, made by that key or a subkey, and signer equal to its fingerprint. A DSA key is refused, and so is an armored field that is not one well-formed block. Model: opus-5-5
537 lines
16 KiB
Go
537 lines
16 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/ProtonMail/go-crypto/openpgp"
|
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
|
"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.
|
|
func TestSignerMismatchMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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)
|
|
}
|
|
|
|
// testSecretKey returns a new OpenPGP key with its secret key, armored, as
|
|
// gpg --export-secret-keys --armor writes it, and the key's fingerprint.
|
|
// The key is protected by passphrase unless that is nil. config sets how
|
|
// the key is made; without one it is an Ed25519 key, which is quick to
|
|
// make.
|
|
func testSecretKey(
|
|
t *testing.T, passphrase []byte, config *packet.Config,
|
|
) ([]byte, string) {
|
|
t.Helper()
|
|
|
|
if config == nil {
|
|
config = &packet.Config{Algorithm: packet.PubKeyAlgoEdDSA}
|
|
}
|
|
|
|
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test", config)
|
|
require.NoError(t, err)
|
|
|
|
if passphrase != nil {
|
|
require.NoError(t, key.EncryptPrivateKeys(passphrase, nil))
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
|
require.NoError(t, err)
|
|
require.NoError(t, key.SerializePrivateWithoutSigning(w, nil))
|
|
require.NoError(t, w.Close())
|
|
|
|
return buf.Bytes(), strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
|
|
}
|
|
|
|
// signedManifest returns a manifest of files signed by a new OpenPGP key.
|
|
func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
|
t.Helper()
|
|
|
|
secretKey, _ := testSecretKey(t, nil, nil)
|
|
|
|
b := mfer.NewBuilder()
|
|
b.SetSigningOptions(&mfer.SigningOptions{SecretKey: secretKey})
|
|
|
|
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 new key, the required key, that did not sign it.
|
|
// The manifest is signed by a second new 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))
|
|
|
|
// One armored block holding both keys, as gpg --export --armor writes
|
|
// two keys.
|
|
var block bytes.Buffer
|
|
|
|
w, err := armor.Encode(&block, openpgp.PublicKeyType, nil)
|
|
require.NoError(t, err)
|
|
|
|
for _, key := range [][]byte{
|
|
required.GetSigningPubKey(), outer.GetSigningPubKey(),
|
|
} {
|
|
decoded, err := armor.Decode(bytes.NewReader(key))
|
|
require.NoError(t, err)
|
|
|
|
_, err = io.Copy(w, decoded.Body)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.NoError(t, w.Close())
|
|
|
|
outer.SigningPubKey = block.Bytes()
|
|
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")
|
|
}
|