Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c1b936c3 |
No files matched your search
@@ -26,12 +26,8 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
|
|||||||
|
|
||||||
- 2026-10-03: added `FuzzNewManifestFromReader` and its seed corpus, which
|
- 2026-10-03: added `FuzzNewManifestFromReader` and its seed corpus, which
|
||||||
`make test` runs, plus `make fuzz` for a one-minute run by hand; the zstd
|
`make test` runs, plus `make fuzz` for a one-minute run by hand; the zstd
|
||||||
decoder now decodes only as many bytes as the parser reads, and has
|
decoder now has `MaxDecompressedSize` as its limit, so a frame claiming a
|
||||||
`MaxDecompressedSize` as its limit, so neither a frame claiming a large size
|
large size can no longer make the parser allocate that size (#65)
|
||||||
nor several frames together can make the parser allocate past that limit (#65)
|
|
||||||
- 2026-10-03: pinned the CLI error messages by driving the functions that emit
|
|
||||||
them in `internal/cli/errmsg_test.go`, and made the freshen mtime-presence
|
|
||||||
test distinguish an absent mtime from the epoch (#87)
|
|
||||||
- 2026-10-03: `script/cibuild` builds the image with the same command as
|
- 2026-10-03: `script/cibuild` builds the image with the same command as
|
||||||
`script/docker`, `--no-cache` included, so the checks in the Dockerfile run on
|
`script/docker`, `--no-cache` included, so the checks in the Dockerfile run on
|
||||||
every build, also on an unchanged tree (#89)
|
every build, also on an unchanged tree (#89)
|
||||||
|
|||||||
+133
-318
@@ -2,352 +2,167 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"fmt"
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// errMsgCase is one pinned user-visible error message.
|
||||||
// messages. The messages are grepped for in CI pipelines and quoted in bug
|
type errMsgCase struct {
|
||||||
// reports, so a reword is a deliberate change, never a refactoring side
|
name string
|
||||||
// effect.
|
err error
|
||||||
//
|
want string
|
||||||
// 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 (
|
const (
|
||||||
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runLocked runs fn while holding runMu, so operations that write to the
|
func checkErrMsgCases(t *testing.T, cases []errMsgCase) {
|
||||||
// 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()
|
t.Helper()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
for _, tc := range cases {
|
||||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
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()
|
t.Parallel()
|
||||||
|
assert.Equal(t, tc.want, tc.err.Error())
|
||||||
_, 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
|
// TestErrorMessagesVerbatim pins the exact rendered text of the CLI's
|
||||||
// manifest. The embedded fingerprint is whatever the generated key produced,
|
// user-visible error messages.
|
||||||
// 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
|
// These strings are an interface: they are grepped for in CI pipelines
|
||||||
func TestSignerMismatchMessage(t *testing.T) {
|
// and quoted in bug reports. The messages are assembled by wrapping
|
||||||
chk := signedChecker(t)
|
// static sentinels, and it is easy to change what a user sees while
|
||||||
|
// only meaning to make an error matchable with errors.Is - which is
|
||||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
// precisely what happened once already. Any change to a string below is
|
||||||
require.NoError(t, err)
|
// therefore a deliberate, separately stated change, never a side effect
|
||||||
|
// of a refactor.
|
||||||
err = verifyRequiredSigner(chk, msgFpB)
|
func TestErrorMessagesVerbatim(t *testing.T) {
|
||||||
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()
|
t.Parallel()
|
||||||
|
|
||||||
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
checkErrMsgCases(t, []errMsgCase{
|
||||||
require.NoError(t, set.Parse([]string{"nope"}))
|
{
|
||||||
|
name: "check: no manifest found",
|
||||||
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
err: fmt.Errorf("%w in %s (looked for index.mf and .index.mf)",
|
||||||
ctx := urfcli.NewContext(nil, set, nil)
|
errNoManifestFound, "/tmp/x"),
|
||||||
|
want: "no manifest found in /tmp/x " +
|
||||||
_, err := mfa.collectInputPaths(ctx.Args())
|
"(looked for index.mf and .index.mf)",
|
||||||
require.ErrorIs(t, err, errPathNotExist)
|
},
|
||||||
assert.EqualError(t, err, "path does not exist: nope")
|
{
|
||||||
}
|
name: "check: invalid fingerprint length",
|
||||||
|
err: fmt.Errorf("%w, got %d", errInvalidFingerprint, 8),
|
||||||
func TestOutputFileExistsMessage(t *testing.T) {
|
want: "invalid fingerprint: must be exactly 40 hex characters, got 8",
|
||||||
t.Parallel()
|
},
|
||||||
|
{
|
||||||
fs := afero.NewMemMapFs()
|
name: "check: manifest not signed",
|
||||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
err: fmt.Errorf("%w, but signature from %s is required",
|
||||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
errManifestNotSigned, msgFpA),
|
||||||
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644))
|
want: "manifest is not signed, but signature from " + msgFpA +
|
||||||
|
" is required",
|
||||||
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
},
|
||||||
set.String("output", "", "")
|
{
|
||||||
set.Bool("force", false, "")
|
name: "check: signer mismatch",
|
||||||
require.NoError(t, set.Parse([]string{"/d"}))
|
err: fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||||
require.NoError(t, set.Set("output", "/out.mf"))
|
msgFpA, errSignerMismatch, msgFpB),
|
||||||
|
want: "embedded signing key fingerprint " + msgFpA +
|
||||||
mfa := &CLIApp{Fs: fs}
|
" does not match required " + msgFpB,
|
||||||
ctx := urfcli.NewContext(nil, set, nil)
|
},
|
||||||
|
{
|
||||||
// generateManifestOperation writes to the process-global logger during
|
name: "gen: path does not exist",
|
||||||
// enumeration, so serialize with the other CLI runs.
|
err: fmt.Errorf("%w: %s", errPathNotExist, "nope"),
|
||||||
err := runLocked(func() error { return mfa.generateManifestOperation(ctx) })
|
want: "path does not exist: nope",
|
||||||
require.ErrorIs(t, err, errOutputExists)
|
},
|
||||||
assert.EqualError(t, err,
|
{
|
||||||
"output file /out.mf already exists (use --force to overwrite)")
|
name: "gen: output file exists",
|
||||||
}
|
err: fmt.Errorf("output file %s %w", "index.mf", errOutputExists),
|
||||||
|
want: "output file index.mf already exists " +
|
||||||
// TestUnknownCommandMessage drives the root command's action. run only logs
|
"(use --force to overwrite)",
|
||||||
// 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.
|
name: "mfer: unknown command",
|
||||||
func TestUnknownCommandMessage(t *testing.T) {
|
err: fmt.Errorf("%w %q", errUnknownCommand, "bogus"),
|
||||||
t.Parallel()
|
want: `unknown command "bogus"`,
|
||||||
|
},
|
||||||
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([]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(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) {
|
// TestFetchErrorMessagesVerbatim pins the fetch and manifest-loader
|
||||||
|
// messages; see TestErrorMessagesVerbatim for why.
|
||||||
|
func TestFetchErrorMessagesVerbatim(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// finishDownload returns the size-mismatch error before it touches the
|
checkErrMsgCases(t, []errMsgCase{
|
||||||
// paths, digest, or entry, so those can be zero here.
|
{
|
||||||
err := finishDownload("", "", 9, 10, nil, nil, nil, nil)
|
name: "manifest_loader: http status",
|
||||||
require.ErrorIs(t, err, errSizeMismatch)
|
err: fmt.Errorf("failed to fetch %s: %w %d",
|
||||||
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9")
|
"https://example.com/index.mf", errHTTPStatus, 404),
|
||||||
|
want: "failed to fetch https://example.com/index.mf: HTTP 404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: manifest http status",
|
||||||
|
err: fmt.Errorf("failed to fetch manifest: %w %d",
|
||||||
|
errHTTPStatus, 404),
|
||||||
|
want: "failed to fetch manifest: HTTP 404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: file http status",
|
||||||
|
err: fmt.Errorf("%w %d", errHTTPStatus, 500),
|
||||||
|
want: "HTTP 500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: empty path",
|
||||||
|
err: errEmptyPath,
|
||||||
|
want: "empty path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: absolute path",
|
||||||
|
err: fmt.Errorf("%w: %s", errAbsolutePath, "/etc/passwd"),
|
||||||
|
want: "absolute path not allowed: /etc/passwd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: path traversal",
|
||||||
|
err: fmt.Errorf("%w: %s", errPathTraversal, "../x"),
|
||||||
|
want: "path traversal not allowed: ../x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: size mismatch",
|
||||||
|
err: fmt.Errorf("%w: expected %d bytes, got %d",
|
||||||
|
errSizeMismatch, 10, 9),
|
||||||
|
want: "size mismatch: expected 10 bytes, got 9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: url required",
|
||||||
|
err: errURLRequired,
|
||||||
|
want: "URL argument required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch: hash mismatch",
|
||||||
|
err: errHashMismatch,
|
||||||
|
want: "hash mismatch",
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashMismatchMessage(t *testing.T) {
|
// TestSentinelsAreMatchable checks that the wrapped forms of the
|
||||||
|
// messages above remain matchable with errors.Is, which is the reason
|
||||||
|
// the sentinels exist at all.
|
||||||
|
func TestSentinelsAreMatchable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A 32-byte digest that matches none of the (empty) manifest hashes.
|
wrapped := fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||||
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{})
|
"a", errSignerMismatch, "b")
|
||||||
require.ErrorIs(t, err, errHashMismatch)
|
require.ErrorIs(t, wrapped, errSignerMismatch)
|
||||||
require.NotErrorIs(t, err, errSizeMismatch)
|
|
||||||
assert.EqualError(t, err, "hash mismatch")
|
wrapped = fmt.Errorf("output file %s %w", "index.mf", errOutputExists)
|
||||||
|
require.ErrorIs(t, wrapped, errOutputExists)
|
||||||
|
|
||||||
|
wrapped = fmt.Errorf("failed to fetch manifest: %w %d", errHTTPStatus, 404)
|
||||||
|
require.ErrorIs(t, wrapped, errHTTPStatus)
|
||||||
|
|
||||||
|
assert.NotErrorIs(t, errHashMismatch, errSizeMismatch)
|
||||||
}
|
}
|
||||||
@@ -110,10 +110,7 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
|||||||
|
|
||||||
const relPath = "file1.txt"
|
const relPath = "file1.txt"
|
||||||
|
|
||||||
// The scanned file's mtime is the Unix epoch. If recordEntry ever misreads
|
mtime := time.Unix(1_700_000_000, 0)
|
||||||
// an absent manifest mtime as the epoch, the "absent" case below would
|
|
||||||
// compare equal to this and be classified unchanged, so the test fails.
|
|
||||||
mtime := time.Unix(0, 0)
|
|
||||||
info := stubFileInfo{size: 8, mtime: mtime}
|
info := stubFileInfo{size: 8, mtime: mtime}
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
|
|||||||
+4
-7
@@ -111,14 +111,11 @@ func (m *manifest) verifyOuterIntegrity() error {
|
|||||||
func (m *manifest) decompressInner() ([]byte, error) {
|
func (m *manifest) decompressInner() ([]byte, error) {
|
||||||
bb := bytes.NewBuffer(m.pbOuter.GetInnerMessage())
|
bb := bytes.NewBuffer(m.pbOuter.GetInnerMessage())
|
||||||
|
|
||||||
// By default the decoder decodes a payload under 128 KiB in full,
|
// The decoder's own limit is 64 GiB. Up to that it sets aside as many
|
||||||
// each frame up to the decoder's limit, before the LimitReader below
|
// bytes as a frame header claims, and it decodes a payload under
|
||||||
// reads any of it. Decoding synchronously and never in full makes it decode
|
// 128 KiB in full before the LimitReader below reads any of it, so it
|
||||||
// only what the LimitReader asks for. Its limit caps the window that
|
// needs MaxDecompressedSize as its limit too.
|
||||||
// a frame header can make it set aside.
|
|
||||||
zr, err := zstd.NewReader(bb,
|
zr, err := zstd.NewReader(bb,
|
||||||
zstd.WithDecoderConcurrency(1),
|
|
||||||
zstd.WithDecodeBuffersBelow(0),
|
|
||||||
zstd.WithDecoderMaxMemory(uint64(MaxDecompressedSize)))
|
zstd.WithDecoderMaxMemory(uint64(MaxDecompressedSize)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("deserialize: zstd reader: %w", err)
|
return nil, fmt.Errorf("deserialize: zstd reader: %w", err)
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FuzzNewManifestFromReader feeds arbitrary bytes to the manifest parser.
|
// FuzzNewManifestFromReader feeds arbitrary bytes to the manifest parser.
|
||||||
@@ -16,8 +14,8 @@ import (
|
|||||||
//
|
//
|
||||||
// For every input the parser must return a manifest or an error, not both
|
// For every input the parser must return a manifest or an error, not both
|
||||||
// and not neither, and must not allocate more than a fixed multiple of its
|
// and not neither, and must not allocate more than a fixed multiple of its
|
||||||
// input and of the decompressed data it may read, plus room for the
|
// input plus MaxDecompressedSize. A panic or a hang fails the test on its
|
||||||
// decoder's window. A panic or a hang fails the test on its own.
|
// own.
|
||||||
func FuzzNewManifestFromReader(f *testing.F) {
|
func FuzzNewManifestFromReader(f *testing.F) {
|
||||||
// A signed manifest makes the parser write the key and signature to a
|
// A signed manifest makes the parser write the key and signature to a
|
||||||
// temporary directory and run gpg on them. With gpg off the PATH and
|
// temporary directory and run gpg on them. With gpg off the PATH and
|
||||||
@@ -40,31 +38,12 @@ func FuzzNewManifestFromReader(f *testing.F) {
|
|||||||
t.Fatalf("got manifest %p and error %v, want exactly one", m, err)
|
t.Fatalf("got manifest %p and error %v, want exactly one", m, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The parser reads at most the declared size plus one byte of
|
// The parser keeps a copy of its input and at most
|
||||||
// decompressed data, and never more than MaxDecompressedSize.
|
// MaxDecompressedSize bytes of decompressed data. Buffers grow by
|
||||||
decompressed := uint64(MaxDecompressedSize)
|
// copying, so reaching those sizes allocates a few times them in
|
||||||
|
// total. Sixteen times leaves room for that, and none for a
|
||||||
outer := new(MFFileOuter)
|
// decoder that sets aside whatever size its input claims.
|
||||||
if validateMagic(data) &&
|
limit := 16 * (uint64(len(data)) + uint64(MaxDecompressedSize))
|
||||||
proto.Unmarshal(data[len(MAGIC):], outer) == nil {
|
|
||||||
size := outer.GetSize()
|
|
||||||
if size > 0 && size < MaxDecompressedSize {
|
|
||||||
decompressed = uint64(size) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// It also keeps a few copies of its input, and the decoder sets
|
|
||||||
// aside a window that a frame header sizes, up to a little over
|
|
||||||
// MaxDecompressedSize. Buffers grow by copying, so reaching those
|
|
||||||
// sizes allocates a few times them in total: sixteen times the
|
|
||||||
// input and the decompressed data, plus twice MaxDecompressedSize
|
|
||||||
// for the window, leaves room for that. The seed whose frame
|
|
||||||
// claims 8 GiB fails if the decoder sets that size aside; the seed
|
|
||||||
// whose two frames together exceed MaxDecompressedSize fails if
|
|
||||||
// the decoder decodes them in full instead of stopping at the
|
|
||||||
// declared size.
|
|
||||||
limit := 16*(uint64(len(data))+decompressed) +
|
|
||||||
2*uint64(MaxDecompressedSize)
|
|
||||||
|
|
||||||
allocated := after.TotalAlloc - before.TotalAlloc
|
allocated := after.TotalAlloc - before.TotalAlloc
|
||||||
if allocated > limit {
|
if allocated > limit {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
go test fuzz v1
|
|
||||||
[]byte("ZNAVSRFG\xa8\x06\x01\xb0\x06\x01\xb8\x06\x80\x80\x80\x01\xc2\x06 fIH\x90\xf9;\x83\xc5\xd5\xd9\x1d\\J|j(sF\x99\x8c\x06\x10G\xc0\xf6\x95\xd2+S\x85\x8e\xa1\xca\x06\x10k/\x1eJ\x93\fM^\x8aq?\"ĝ\x05\xe8\xba\f\x9c@(\xb5/\xfd\x008\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x03\x00\x10\x00(\xb5/\xfd\x008\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\x02\x00\x10\x00\Line truncated
|
|
||||||
Reference in new issue
Block a user