515 lines
15 KiB
Go
515 lines
15 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"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. It is an Ed25519
|
|
// key, which is quick to make.
|
|
func testSecretKey(t *testing.T, passphrase []byte) ([]byte, string) {
|
|
t.Helper()
|
|
|
|
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test",
|
|
&packet.Config{Algorithm: packet.PubKeyAlgoEdDSA})
|
|
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)
|
|
|
|
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))
|
|
|
|
// Armored blocks start on a line of their own; mfer, unlike gpg, ends
|
|
// one without a newline.
|
|
outer.SigningPubKey = slices.Concat(
|
|
required.GetSigningPubKey(), []byte("\n"), 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")
|
|
}
|