Compare commits
1
Commits
next
..
9f9092721f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9092721f |
@@ -13,15 +13,9 @@ The structure of these files can be found
|
||||
schema `mfer/mf.proto` for exact field numbers and types. Both are included in
|
||||
the [project repository](https://git.eeqj.de/sneak/mfer).
|
||||
|
||||
Releases are git tags named by [SemVer](https://semver.org), starting with
|
||||
`v1.0.0`. No file in the tree records a version number. `mfer version` prints
|
||||
the revision the build stamped into the binary, which for `make build` and the
|
||||
Docker image is the output of `git describe` on the checkout they build. A
|
||||
binary built without that stamp, as
|
||||
`go install sneak.berlin/go/mfer/cmd/mfer@<tag>` builds it, prints the module
|
||||
version Go records in it instead. A program that imports the `mfer` package
|
||||
finds the version it was built against in its own build information
|
||||
(`runtime/debug.ReadBuildInfo`).
|
||||
The current version is pre-1.0 and while the repo was published in 2022, there
|
||||
has not yet been any versioned release. [SemVer](https://semver.org) will be
|
||||
used for releases.
|
||||
|
||||
This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
|
||||
2022 and is currently a one-person effort, though the goal is for this to emerge
|
||||
@@ -295,12 +289,9 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
||||
else the directory holding the manifest, or the current directory for a
|
||||
manifest given by URL. So `mfer check /media/drive` checks a drive against
|
||||
the `index.mf` at its root, from any directory
|
||||
- warns about each regular file under the base directory that the manifest
|
||||
does not list, hidden files included, and each symlink there to a regular
|
||||
file, which gen lists only with `--follow-symlinks`; with
|
||||
`--no-extra-files` each one is a failure instead. A named pipe, socket or
|
||||
device, a symlink to a directory and a broken symlink are never reported,
|
||||
since gen cannot list them
|
||||
- warns about each file under the base directory that the manifest does not
|
||||
list, hidden files included; with `--no-extra-files` each one is a failure
|
||||
instead
|
||||
- `mfer freshen` / `mfer freshen .`
|
||||
- rewrites `index.mf` to list the files now under the directory holding it,
|
||||
or under the one `--base` names, hashing only the files that are new or
|
||||
@@ -309,9 +300,6 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
||||
- leaves out hidden files unless given `--include-dotfiles`, and symlinks
|
||||
unless given `--follow-symlinks`, which lists each symlink to a regular
|
||||
file under its own name with the contents of the file it points to
|
||||
- refuses a signed manifest, leaving it as it was and naming the fingerprint
|
||||
of the key that signed it, unless `--sign-key` or `MFER_SIGN_KEY` names a
|
||||
key to sign the new one with
|
||||
- `mfer gen --sign-key key.asc` / `mfer freshen --sign-key key.asc`
|
||||
- signs the manifest with the OpenPGP secret key in `key.asc`, armored or
|
||||
binary, as `gpg --export-secret-keys` writes it; `MFER_SIGN_KEY` names the
|
||||
|
||||
+6
-4
@@ -10,12 +10,14 @@ import (
|
||||
// Appname is the name of this program.
|
||||
const Appname = "mfer"
|
||||
|
||||
// Gitrev is the revision `mfer version` prints, injected at build time via
|
||||
// -ldflags.
|
||||
// Version and Gitrev are injected at build time via -ldflags.
|
||||
//
|
||||
//nolint:gochecknoglobals // set via ldflags at build time
|
||||
var Gitrev string
|
||||
var (
|
||||
Version string
|
||||
Gitrev string
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Run(Appname, Gitrev))
|
||||
os.Exit(cli.Run(Appname, Version, Gitrev))
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ func TestAppname(t *testing.T) {
|
||||
assert.Equal(t, "mfer", Appname)
|
||||
}
|
||||
|
||||
// TestGitrevDefault documents that Gitrev is empty unless injected at build
|
||||
// time via -ldflags.
|
||||
func TestGitrevDefault(t *testing.T) {
|
||||
// TestVersionDefaults documents that Version and Gitrev are empty unless
|
||||
// injected at build time via -ldflags.
|
||||
func TestVersionDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Empty(t, Version)
|
||||
assert.Empty(t, Gitrev)
|
||||
}
|
||||
|
||||
+31
-62
@@ -80,28 +80,29 @@ After decompressing `innerMessage`, the result is a serialized `MFFile`
|
||||
|
||||
Each file entry contains:
|
||||
|
||||
| Field | Number | Type | Description |
|
||||
| ---------- | ------ | ------------------------- | ------------------------------------------------------------ |
|
||||
| `path` | 1 | string | Relative file path (see Path Rules) |
|
||||
| `size` | 2 | int64 | File size in bytes |
|
||||
| `hashes` | 3 | repeated `MFFileChecksum` | Must include exactly one SHA-256 multihash (see Hash Format) |
|
||||
| `mimeType` | 301 | string (optional) | MIME type |
|
||||
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
||||
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
||||
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
||||
| Field | Number | Type | Description |
|
||||
| ---------- | ------ | ------------------------- | ------------------------------------------------------------- |
|
||||
| `path` | 1 | string | Relative file path (see Path Rules) |
|
||||
| `size` | 2 | int64 | File size in bytes |
|
||||
| `hashes` | 3 | repeated `MFFileChecksum` | At least one hash required; readers reject an entry with none |
|
||||
| `mimeType` | 301 | string (optional) | MIME type |
|
||||
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
||||
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
||||
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
||||
|
||||
## File Mode
|
||||
|
||||
`mode` holds a file's Unix permission bits, the nine `rwx` bits, so it is never
|
||||
above `0777` (octal); the setuid, setgid and sticky bits are never recorded. A
|
||||
reader must reject a manifest with an entry whose mode is above `0777`. Writers
|
||||
record `0000` unless whoever creates the manifest asks for permissions. `0000`,
|
||||
the proto3 default, means no mode was recorded: readers never check or apply it.
|
||||
above `0777` (octal); the setuid, setgid and sticky bits are never recorded.
|
||||
Writers record `0000` unless whoever creates the manifest asks for permissions.
|
||||
`0000`, the proto3 default, means no mode was recorded: readers never check or
|
||||
apply it.
|
||||
|
||||
The reference implementation records modes when `gen` or `freshen` is given
|
||||
`--include-permissions`. `check` fails a file whose permission bits differ from
|
||||
a recorded mode other than `0000`. `fetch` sets a recorded mode other than
|
||||
`0000` on each file it writes.
|
||||
`0000` on each file it writes, and refuses a manifest that records a mode above
|
||||
`0777` before it requests any file.
|
||||
|
||||
## Path Rules
|
||||
|
||||
@@ -111,7 +112,6 @@ All `path` values must satisfy these invariants:
|
||||
- **Forward slashes**: use `/` as the path separator (never `\`)
|
||||
- **Relative only**: no leading `/`
|
||||
- **No parent traversal**: no `..` path segments
|
||||
- **No current-directory segments**: no `.` path segments
|
||||
- **No empty segments**: no `//` sequences
|
||||
- **No trailing slash**: paths refer to files, not directories
|
||||
- **Listed once**: each path appears at most once in a manifest, compared byte
|
||||
@@ -132,16 +132,8 @@ Each checksum is a single `bytes multiHash` field containing a
|
||||
self-describing: the encoded bytes include a varint algorithm identifier
|
||||
followed by a varint digest length followed by the digest itself.
|
||||
|
||||
A writer must give every file entry exactly one SHA-256 multihash of the file's
|
||||
content: the algorithm code `0x12`, the digest length `0x20` and the 32 bytes of
|
||||
the digest. It may list multihashes of other algorithms beside it. A reader must
|
||||
refuse a manifest with an entry that has no SHA-256 multihash or more than one,
|
||||
whatever other hashes the entry lists. A verifier then needs only SHA-256,
|
||||
checking each file against its entry's SHA-256 multihash; it may ignore the
|
||||
other hashes.
|
||||
|
||||
The reference implementation writes one hash for each entry, the SHA-256
|
||||
multihash, and its `check` and `fetch` compute only SHA-256.
|
||||
The 1.0 implementation writes SHA-256 multihashes (`0x12` algorithm code).
|
||||
Implementations must be able to verify SHA-256 multihashes at minimum.
|
||||
|
||||
## Signature Scheme
|
||||
|
||||
@@ -191,24 +183,14 @@ also refuses a manifest whose `signingPubKey` holds a DSA key or subkey, or any
|
||||
secret key or subkey, since checking the self-signatures of a DSA key or the
|
||||
numbers of a secret key can take hours when those numbers are very large.
|
||||
|
||||
A signature is good only if the primary key, and the subkey that made the
|
||||
signature, were valid at the signature's creation time under the key validity
|
||||
rules of RFC 9580. The key is judged by its current self-signatures and binding
|
||||
signatures: for a version 4 key the newest self-signature on its primary user
|
||||
ID, for a version 6 key its newest direct-key signature, and for a subkey its
|
||||
newest binding signature with the signature embedded in it. So a signature made
|
||||
before the newest of those is not good, even if an older one made the key valid
|
||||
at that time. The reference implementation checks this by verifying the
|
||||
signature a second time with its clock set to the signature's creation time. A
|
||||
key that has expired since does not make the signature bad, because a manifest
|
||||
outlives its key. A signature past its own expiry time is not good, whether or
|
||||
not the key has expired too.
|
||||
A signing key that has expired does not make the signature bad, because a
|
||||
manifest outlives its key. A signature past its own expiry time is not good,
|
||||
whether or not the key has expired too.
|
||||
|
||||
## Deterministic Serialization
|
||||
|
||||
The reference implementation writes the same inner message for the same files,
|
||||
UUID and options, unless the options include `--include-timestamps`. It
|
||||
compresses the same inner message to the same bytes:
|
||||
UUID and options, and compresses the same inner message to the same bytes:
|
||||
|
||||
- File entries are sorted by `path` in byte order, so `B.txt` comes before
|
||||
`a.txt`.
|
||||
@@ -216,21 +198,10 @@ compresses the same inner message to the same bytes:
|
||||
marshaling, which writes fields in field-number order and leaves out the
|
||||
fields that are not set. A field not declared `optional` counts as not set
|
||||
when it holds its default value, zero or empty.
|
||||
- Each entry holds only `path`, `size`, `hashes` with exactly one hash, `mtime`
|
||||
and `mode`. The hash is the SHA-256 multihash of the file's content, except
|
||||
that `freshen` reads only the files that are new or whose size, `mtime` or
|
||||
`mode` changed, and for every other file carries the SHA-256 multihash of its
|
||||
existing entry over unchanged, leaving out any other hash the entry lists.
|
||||
`mtime` is the file's modification time as the file system gives it, to the
|
||||
nanosecond, and `mode` is `0000` unless `gen` or `freshen` is given
|
||||
- Each entry's `mtime` is the file's modification time as the file system gives
|
||||
it, to the nanosecond. `createdAt` is left out unless `gen` or `freshen` is
|
||||
given `--include-timestamps`, and `mode` is `0000` unless it is given
|
||||
`--include-permissions`.
|
||||
- `createdAt` is left out unless `gen` or `freshen` is given
|
||||
`--include-timestamps`. Then it holds the time the run started building the
|
||||
manifest, to the nanosecond, so it differs on every run.
|
||||
- A time, in `mtime` or `createdAt`, is written as `seconds`, the whole seconds
|
||||
since 1970-01-01 00:00:00 UTC rounded down, and `nanos`, the rest, from 0
|
||||
to 999999999. So a time before 1970 has negative `seconds` and `nanos` of 0 or
|
||||
more.
|
||||
- The inner message is compressed into one zstd frame, with a checksum of its
|
||||
content, by the encoder of the Go library `github.com/klauspost/compress` at
|
||||
its `SpeedBestCompression` level and otherwise its default settings. That
|
||||
@@ -243,9 +214,9 @@ compresses the same inner message to the same bytes:
|
||||
bytes.
|
||||
|
||||
So two runs of `gen` over an unchanged tree write byte-identical files only when
|
||||
both are given the same `--seed`, neither is given `--include-timestamps`, and
|
||||
neither signs the manifest: a signature records the time it was made, and the
|
||||
OpenPGP library adds a random salt to each one.
|
||||
both are given the same `--seed` and neither signs the manifest: a signature
|
||||
records the time it was made, and the OpenPGP library adds a random salt to each
|
||||
one.
|
||||
|
||||
## Test Vector
|
||||
|
||||
@@ -253,9 +224,8 @@ OpenPGP library adds a random salt to each one.
|
||||
manifest the reference implementation made once, with the UUID
|
||||
`92197fd5-32bc-4090-8aa9-f86e5518b56c` and an Ed25519 key that the OpenPGP
|
||||
library made for this file and nothing else; its secret key was not kept.
|
||||
`TestFormatTestVector` in `mfer/deserialize_test.go` checks that the file's
|
||||
SHA-256, its canonical string and `signer` are the ones shown here, and verifies
|
||||
its signature.
|
||||
`TestFormatTestVector` in `mfer/deserialize_test.go` loads the file, verifies
|
||||
its signature and checks the signed string and `signer` shown here.
|
||||
|
||||
A reader should load the file and accept its signature. A writer given the same
|
||||
files and UUID should give the same inner message; its compressed bytes, and so
|
||||
@@ -293,7 +263,7 @@ Its compressed form is hashed into `sha256`:
|
||||
2353553205f036a88cefee3d0d9049ccd8e68bb24011a0b462b5867f1fcc6352
|
||||
```
|
||||
|
||||
So the canonical string is:
|
||||
So the signed string is:
|
||||
|
||||
```
|
||||
ZNAVSRFG-92197fd532bc40908aa9f86e5518b56c-12202353553205f036a88cefee3d0d9049ccd8e68bb24011a0b462b5867f1fcc6352
|
||||
@@ -312,8 +282,7 @@ oIvrZUgEw8DUb9JeK7TChOi6PUAHUFY75Fp6hAE=
|
||||
-----END PGP SIGNATURE-----
|
||||
```
|
||||
|
||||
The whole file, 1056 bytes, whose SHA-256 is
|
||||
`5864187d671b2a55f8e015f858248f92c51956b549cf548b6bb54477c2407fd4`:
|
||||
The whole file, 1056 bytes:
|
||||
|
||||
```
|
||||
00000000 5a 4e 41 56 53 52 46 47 a8 06 01 b0 06 01 b8 06 |ZNAVSRFG........|
|
||||
|
||||
+54
-36
@@ -2,7 +2,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -95,36 +94,47 @@ func findManifest(fs afero.Fs, dir string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// downloadManifest downloads the manifest at url into memory. It refuses a
|
||||
// manifest larger than mfa.maxManifestSize.
|
||||
func (mfa *CLIApp) downloadManifest(ctx context.Context, url string) ([]byte, error) {
|
||||
rc, err := mfa.openManifestReader(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// fetchManifestToTemp downloads a manifest URL to a temporary file and
|
||||
// returns the temp file path. The caller is responsible for removing it.
|
||||
func (mfa *CLIApp) fetchManifestToTemp(
|
||||
ctx context.Context, url string,
|
||||
) (string, error) {
|
||||
rc, fetchErr := mfa.openManifestReader(ctx, url)
|
||||
if fetchErr != nil {
|
||||
return "", fetchErr
|
||||
}
|
||||
|
||||
defer func() { _ = rc.Close() }()
|
||||
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
||||
if tmpErr != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
// Reading stops one byte past mfa.maxManifestSize, which is enough to
|
||||
return "", tmpErr
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
// Copying stops one byte past mfa.maxManifestSize, which is enough to
|
||||
// tell that the manifest is too large.
|
||||
manifestData, err := io.ReadAll(io.LimitReader(rc, mfa.maxManifestSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download manifest: %w", err)
|
||||
written, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfa.maxManifestSize+1))
|
||||
_ = rc.Close()
|
||||
_ = tmpFile.Close()
|
||||
|
||||
if cpErr == nil && written > mfa.maxManifestSize {
|
||||
cpErr = fmt.Errorf("%w of %d bytes", errManifestTooLarge, mfa.maxManifestSize)
|
||||
}
|
||||
|
||||
if int64(len(manifestData)) > mfa.maxManifestSize {
|
||||
return nil, fmt.Errorf("download manifest: %w of %d bytes",
|
||||
errManifestTooLarge, mfa.maxManifestSize)
|
||||
if cpErr != nil {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
|
||||
return "", fmt.Errorf("download manifest: %w", cpErr)
|
||||
}
|
||||
|
||||
return manifestData, nil
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||
// against signer, the fingerprint of the key that made the manifest's
|
||||
// signature, which is empty when the manifest is not signed. Loading the
|
||||
// manifest checked that signer is that key's fingerprint.
|
||||
func verifyRequiredSigner(signer, requiredSigner string) error {
|
||||
// against the key that made the manifest's signature.
|
||||
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
// Validate fingerprint format: must be exactly 40 hex characters
|
||||
if len(requiredSigner) != fingerprintHexLen {
|
||||
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
||||
@@ -135,11 +145,15 @@ func verifyRequiredSigner(signer, requiredSigner string) error {
|
||||
return fmt.Errorf("invalid fingerprint: %w", err)
|
||||
}
|
||||
|
||||
if signer == "" {
|
||||
if !chk.IsSigned() {
|
||||
return fmt.Errorf("%w, but signature from %s is required",
|
||||
errManifestNotSigned, requiredSigner)
|
||||
}
|
||||
|
||||
// Loading the manifest checked that the signer is the fingerprint of
|
||||
// the key that made the signature.
|
||||
signer := string(chk.Signer())
|
||||
|
||||
// Compare fingerprints - must be exact match (case-insensitive)
|
||||
if !strings.EqualFold(signer, requiredSigner) {
|
||||
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
@@ -285,36 +299,40 @@ func (mfa *CLIApp) checkManifestOperation(
|
||||
return err
|
||||
}
|
||||
|
||||
// Done before a URL is swapped for the temp file it is downloaded to,
|
||||
// whose directory is not the base.
|
||||
basePath := resolveBasePath(cmd, manifestPath)
|
||||
|
||||
opts := &mfer.CheckerOptions{BasePath: basePath, Fs: mfa.Fs}
|
||||
|
||||
// A manifest given by URL is downloaded into memory and read from
|
||||
// there.
|
||||
// URL manifests need to be downloaded to a temp file for the checker
|
||||
if isHTTPURL(manifestPath) {
|
||||
manifestData, downloadErr := mfa.downloadManifest(ctx, manifestPath)
|
||||
if downloadErr != nil {
|
||||
return downloadErr
|
||||
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
|
||||
if tmpErr != nil {
|
||||
return tmpErr
|
||||
}
|
||||
|
||||
opts.ManifestReader = bytes.NewReader(manifestData)
|
||||
} else {
|
||||
opts.ManifestPath = manifestPath
|
||||
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
||||
|
||||
manifestPath = tmpPath
|
||||
}
|
||||
|
||||
showProgress := cmd.Bool("progress")
|
||||
|
||||
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
||||
|
||||
chk, err := mfer.NewChecker(opts)
|
||||
// Create checker
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: manifestPath,
|
||||
BasePath: basePath,
|
||||
Fs: mfa.Fs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check signature requirement. An empty fingerprint is checked too,
|
||||
// and refused as one of the wrong length.
|
||||
if cmd.IsSet(flagRequireSignature) {
|
||||
err = verifyRequiredSigner(string(chk.Signer()), cmd.String(flagRequireSignature))
|
||||
// Check signature requirement
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if requiredSigner != "" {
|
||||
err = verifyRequiredSigner(chk, requiredSigner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ func noColorEnvSet() bool {
|
||||
// Use DefaultRunOptions for standard CLI execution, or construct manually for testing.
|
||||
type RunOptions struct {
|
||||
Appname string // Application name displayed in help and version output
|
||||
Gitrev string // Revision stamped in at build time, or empty
|
||||
Version string // Version string (typically set at build time)
|
||||
Gitrev string // Git revision hash (typically set at build time)
|
||||
Args []string // Command-line arguments (typically os.Args)
|
||||
Stdin io.Reader // Standard input stream
|
||||
Stdout io.Writer // Standard output stream
|
||||
@@ -35,9 +36,10 @@ type RunOptions struct {
|
||||
}
|
||||
|
||||
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
|
||||
func DefaultRunOptions(appname, gitrev string) *RunOptions {
|
||||
func DefaultRunOptions(appname, version, gitrev string) *RunOptions {
|
||||
return &RunOptions{
|
||||
Appname: appname,
|
||||
Version: version,
|
||||
Gitrev: gitrev,
|
||||
Args: os.Args,
|
||||
Stdin: os.Stdin,
|
||||
@@ -48,14 +50,15 @@ func DefaultRunOptions(appname, gitrev string) *RunOptions {
|
||||
}
|
||||
|
||||
// Run creates and runs the CLI application with default options.
|
||||
func Run(appname, gitrev string) int {
|
||||
return RunWithOptions(DefaultRunOptions(appname, gitrev))
|
||||
func Run(appname, version, gitrev string) int {
|
||||
return RunWithOptions(DefaultRunOptions(appname, version, gitrev))
|
||||
}
|
||||
|
||||
// RunWithOptions creates and runs the CLI application with the given options.
|
||||
func RunWithOptions(opts *RunOptions) int {
|
||||
m := &CLIApp{
|
||||
appname: opts.Appname,
|
||||
version: opts.Version,
|
||||
gitrev: opts.Gitrev,
|
||||
exitCode: 0,
|
||||
maxManifestSize: mfer.MaxManifestSize,
|
||||
|
||||
+28
-213
@@ -11,7 +11,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -28,7 +27,6 @@ import (
|
||||
|
||||
const (
|
||||
testApp = "mfer"
|
||||
testGitrev = "abc123"
|
||||
testDir = "/testdir"
|
||||
testFile1 = "/testdir/file1.txt"
|
||||
testMF = "/testdir/test.mf"
|
||||
@@ -38,8 +36,6 @@ const (
|
||||
testFlagNoExtra = "--no-extra-files"
|
||||
testFlagVersion = "--version"
|
||||
testFlagVerbose = "--verbose"
|
||||
testFlagHelp = "--help"
|
||||
testCmdHelp = "help"
|
||||
)
|
||||
|
||||
var errSimulatedWrite = errors.New("simulated write failure")
|
||||
@@ -62,10 +58,9 @@ func runCLI(opts *RunOptions) int {
|
||||
// runCLIWithLevel is runCLI that also returns the log level the run left
|
||||
// set, read while runMu still keeps other runs from changing it. Each run
|
||||
// starts at the default level, as a new process does. Before releasing the
|
||||
// lock it points the process-global logger at io.Discard: test helpers also
|
||||
// log under runMu outside any run (manifest loads, scans), and those lines
|
||||
// must not land in this run's buffers once it has returned and its test is
|
||||
// reading them.
|
||||
// lock it points the process-global logger at io.Discard: other tests log
|
||||
// outside the lock (manifest loads, scans), and those lines must not land in
|
||||
// this run's buffers once it has returned and its test is reading them.
|
||||
func runCLIWithLevel(opts *RunOptions) (int, log.Level) {
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
@@ -98,7 +93,8 @@ func TestBuild(t *testing.T) {
|
||||
func testOpts(args []string, fs afero.Fs) *RunOptions {
|
||||
return &RunOptions{
|
||||
Appname: testApp,
|
||||
Gitrev: testGitrev,
|
||||
Version: "1.0.0",
|
||||
Gitrev: "abc123",
|
||||
Args: args,
|
||||
Stdin: &bytes.Buffer{},
|
||||
Stdout: &bytes.Buffer{},
|
||||
@@ -140,35 +136,10 @@ func TestVersionCommand(t *testing.T) {
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode)
|
||||
assert.Equal(t, "mfer version "+testGitrev+"\n", testStdout(t, opts))
|
||||
}
|
||||
|
||||
// TestVersionFromBuildInfo covers a binary whose build stamped no Gitrev,
|
||||
// as `go install` builds it: mfer version prints the main module's version
|
||||
// from the build information instead.
|
||||
func TestVersionFromBuildInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, info.Main.Version)
|
||||
|
||||
opts := testOpts([]string{testApp, cmdVersion}, afero.NewMemMapFs())
|
||||
opts.Gitrev = ""
|
||||
|
||||
require.Equal(t, 0, runCLI(opts))
|
||||
assert.Equal(t, "mfer version "+info.Main.Version+"\n", testStdout(t, opts))
|
||||
}
|
||||
|
||||
// TestModuleVersionEmpty covers build information that holds no module
|
||||
// version, as in a build with GO111MODULE=off, and a binary with no build
|
||||
// information at all. Both report "unknown": an empty version would print
|
||||
// nothing after "mfer version" and drop the --version flag.
|
||||
func TestModuleVersionEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "unknown", moduleVersion(&debug.BuildInfo{}, true))
|
||||
assert.Equal(t, "unknown", moduleVersion(nil, false))
|
||||
stdout := testStdout(t, opts)
|
||||
assert.Contains(t, stdout, mfer.Version)
|
||||
assert.Contains(t, stdout, "abc123")
|
||||
}
|
||||
|
||||
// TestVFlagCollision covers the -v/--verbose vs --version flag interaction
|
||||
@@ -194,7 +165,7 @@ func TestVFlagCollision(t *testing.T) {
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
||||
assert.Contains(t, testStdout(t, opts), testGitrev)
|
||||
assert.Contains(t, testStdout(t, opts), mfer.Version)
|
||||
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
|
||||
})
|
||||
}
|
||||
@@ -235,13 +206,13 @@ func TestVersionFlagAndCommandMatch(t *testing.T) {
|
||||
|
||||
// TestRootVerbosityFlags asserts that -q and -v given before any subcommand
|
||||
// name take effect: in the root itself, and in the fetch and export
|
||||
// subcommands (issue #64).
|
||||
// subcommands (issue #64). It does not run in parallel: other tests log
|
||||
// outside runMu (manifest loads, scans), and a line logged while one of these
|
||||
// runs is in progress lands in its output.
|
||||
//
|
||||
//nolint:paralleltest // see above
|
||||
func TestRootVerbosityFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("quiet with no subcommand hides the banner", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loud := testOpts([]string{testApp}, afero.NewMemMapFs())
|
||||
require.Equal(t, 0, runCLI(loud))
|
||||
assert.Contains(t, testStdout(t, loud), banner)
|
||||
@@ -253,8 +224,6 @@ func TestRootVerbosityFlags(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("quiet before fetch hides the banner", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts([]string{testApp, "-q", cmdFetch}, afero.NewMemMapFs())
|
||||
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
@@ -263,8 +232,6 @@ func TestRootVerbosityFlags(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("verbose twice before fetch enables debug logging", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts([]string{testApp, "-v", "-v", cmdFetch}, afero.NewMemMapFs())
|
||||
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
@@ -272,8 +239,6 @@ func TestRootVerbosityFlags(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("quiet before export hides the manifest summary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
manifest := buildTestManifest(t, map[string][]byte{"a.txt": []byte("a")})
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
|
||||
@@ -356,7 +321,7 @@ func TestHelpCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{testApp, testFlagHelp}, fs)
|
||||
opts := testOpts([]string{testApp, "--help"}, fs)
|
||||
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
@@ -774,9 +739,6 @@ func TestBannerOutput(t *testing.T) {
|
||||
stdout := testStdout(t, opts)
|
||||
assert.Contains(t, stdout, "___")
|
||||
assert.Contains(t, stdout, "\\")
|
||||
|
||||
// The banner shows the version mfer version prints, and nothing after it.
|
||||
assert.Contains(t, stdout, "mfer by @sneak: "+testGitrev+"\n")
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
@@ -809,9 +771,13 @@ func TestGenerateExcludesDotfilesByDefault(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify manifest only has 1 file (the non-dotfile)
|
||||
paths := manifestPaths(t, fs, testMF)
|
||||
require.Len(t, paths, 1)
|
||||
assert.Equal(t, "file1.txt", paths[0])
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: testMF,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 1)
|
||||
assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath())
|
||||
}
|
||||
|
||||
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||
@@ -832,7 +798,12 @@ func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Verify manifest has 2 files (including dotfile)
|
||||
assert.Len(t, manifestPaths(t, fs, testMF), 2)
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: testMF,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
}
|
||||
|
||||
func TestMultipleInputPaths(t *testing.T) {
|
||||
@@ -989,53 +960,6 @@ func TestCheckWarnsAboutUnlistedFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckReportsUnlistedSymlinkToFileOnly makes a tree holding a symlink
|
||||
// to a directory, one to a file and a broken one. gen lists the symlink to
|
||||
// the file only with --follow-symlinks, and never lists the other two, so
|
||||
// check --no-extra-files passes on the manifest gen --follow-symlinks writes
|
||||
// and fails only on the symlink to the file on the one plain gen writes.
|
||||
func TestCheckReportsUnlistedSymlinkToFileOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
genFlags []string
|
||||
exitCode int
|
||||
extras []string // what check reports as not in the manifest
|
||||
}{
|
||||
"gen --follow-symlinks": {[]string{"--follow-symlinks"}, 0, nil},
|
||||
"plain gen": {nil, 1, []string{"filelink"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
fs := afero.NewOsFs()
|
||||
writeTestTree(t, fs, dir)
|
||||
require.NoError(t, os.Symlink(testSubdir, filepath.Join(dir, "dirlink")))
|
||||
require.NoError(t, os.Symlink(testFileTxt, filepath.Join(dir, "filelink")))
|
||||
require.NoError(t,
|
||||
os.Symlink("nowhere", filepath.Join(dir, "brokenlink")))
|
||||
|
||||
opts := testOpts(slices.Concat(
|
||||
[]string{testApp, cmdGenerate, "-q"}, tc.genFlags, []string{dir},
|
||||
), fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
opts = testOpts([]string{testApp, cmdCheck, testFlagNoExtra, dir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
stderr := testStderr(t, opts)
|
||||
|
||||
assert.Equal(t, tc.exitCode, exitCode, "stderr: %s", stderr)
|
||||
assert.Equal(t, len(tc.extras),
|
||||
strings.Count(stderr, "not in manifest"), stderr)
|
||||
|
||||
for _, extra := range tc.extras {
|
||||
assert.Contains(t, stderr, "EXTRA: "+extra)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNeverReportsManifest keeps the manifest inside the checked tree,
|
||||
// under several names and path spellings, and names the tree both directly
|
||||
// and through a symlink: the manifest is never reported, even under
|
||||
@@ -1167,29 +1091,6 @@ func TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory(t *testing.T) {
|
||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
}
|
||||
|
||||
// TestCheckURLManifestWithoutTempDirectory checks the tree under --base
|
||||
// against a manifest given by URL while TMPDIR names a directory that does
|
||||
// not exist. check keeps the manifest it downloads in memory, so it passes.
|
||||
// Setting TMPDIR keeps it from running in parallel with other tests.
|
||||
func TestCheckURLManifestWithoutTempDirectory(t *testing.T) {
|
||||
files := map[string][]byte{testFileTxt: []byte("hello")}
|
||||
|
||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
|
||||
defer server.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
require.NoError(t,
|
||||
os.WriteFile(filepath.Join(base, testFileTxt), files[testFileTxt], 0o600))
|
||||
|
||||
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdCheck, testFlagNoExtra, testFlagBase, base,
|
||||
server.URL + "/" + defaultManifestName,
|
||||
}, afero.NewOsFs())
|
||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
}
|
||||
|
||||
// unlistableDirFs is a filesystem on which one directory cannot be listed.
|
||||
type unlistableDirFs struct {
|
||||
afero.Fs
|
||||
@@ -1323,11 +1224,6 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
||||
func manifestPaths(t *testing.T, fs afero.Fs, path string) []string {
|
||||
t.Helper()
|
||||
|
||||
// Loading a manifest logs to the process-global logger, so serialize
|
||||
// with the CLI runs.
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: path,
|
||||
Fs: fs,
|
||||
@@ -1911,87 +1807,6 @@ func TestGenerateReadsPathAfterDoubleDash(t *testing.T) {
|
||||
assert.Equal(t, testFileTxt, files[0].GetPath())
|
||||
}
|
||||
|
||||
// TestPathNamedHelp runs gen, then check with --require-signature, on a
|
||||
// directory named help and on one named h, the two names of urfave/cli's
|
||||
// help command. gen must write the manifest into the directory, and check
|
||||
// must refuse it as unsigned.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestPathNamedHelp(t *testing.T) {
|
||||
for _, name := range []string{testCmdHelp, "h"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
require.NoError(t, fs.Mkdir(name, 0o755))
|
||||
writeTestFile(t, fs, filepath.Join(name, testFileTxt), "content")
|
||||
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", name}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
|
||||
files := manifestFiles(t, fs, filepath.Join(name, defaultManifestName))
|
||||
require.Len(t, files, 1)
|
||||
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", name, "--" + flagRequireSignature, msgFpA,
|
||||
}, fs)
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts), "manifest is not signed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandsReadHelpAsArgument runs each command that takes a path or URL
|
||||
// on help and on h where nothing by that name exists. Each must fail on the
|
||||
// missing path or bad URL instead of printing its help.
|
||||
func TestCommandsReadHelpAsArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range []string{
|
||||
cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch,
|
||||
} {
|
||||
for _, name := range []string{testCmdHelp, "h"} {
|
||||
t.Run(command+" "+name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts([]string{testApp, command, name}, afero.NewMemMapFs())
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.NotContains(t, testStdout(t, opts), "USAGE:")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHelpBesideArgument runs each command that takes a path or URL with
|
||||
// --help or -h before and after a directory, and runs help on that command.
|
||||
// Each must print what the command's own --help prints.
|
||||
func TestHelpBesideArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range []string{
|
||||
cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch,
|
||||
} {
|
||||
want := testOpts([]string{testApp, command, testFlagHelp}, afero.NewMemMapFs())
|
||||
require.Equal(t, 0, runCLI(want))
|
||||
require.Contains(t, testStdout(t, want), testApp+" "+command)
|
||||
|
||||
for _, args := range [][]string{
|
||||
{command, testDir, testFlagHelp},
|
||||
{command, testFlagHelp, testDir},
|
||||
{command, testDir, "-h"},
|
||||
{testCmdHelp, command},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testOpts(slices.Concat([]string{testApp}, args), afero.NewMemMapFs())
|
||||
assert.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
assert.Equal(t, testStdout(t, want), testStdout(t, opts))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+33
-54
@@ -50,6 +50,33 @@ func runLocked(fn func() error) error {
|
||||
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()
|
||||
|
||||
@@ -65,7 +92,7 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
t.Run("invalid fingerprint length", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := verifyRequiredSigner("", "12345678")
|
||||
err := verifyRequiredSigner(unsignedChecker(t), "12345678")
|
||||
require.ErrorIs(t, err, errInvalidFingerprint)
|
||||
assert.EqualError(t, err,
|
||||
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
||||
@@ -74,7 +101,7 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
t.Run("manifest not signed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := verifyRequiredSigner("", msgFpA)
|
||||
err := verifyRequiredSigner(unsignedChecker(t), msgFpA)
|
||||
require.ErrorIs(t, err, errManifestNotSigned)
|
||||
assert.EqualError(t, err,
|
||||
"manifest is not signed, but signature from "+msgFpA+" is required")
|
||||
@@ -89,13 +116,13 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
func TestSignerMismatchMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
signer := string(signedChecker(t,
|
||||
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")})).Signer())
|
||||
chk := signedChecker(t,
|
||||
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
|
||||
|
||||
err := verifyRequiredSigner(signer, msgFpB)
|
||||
err := verifyRequiredSigner(chk, msgFpB)
|
||||
require.ErrorIs(t, err, errSignerMismatch)
|
||||
assert.EqualError(t, err,
|
||||
"embedded signing key fingerprint "+signer+
|
||||
"embedded signing key fingerprint "+string(chk.Signer())+
|
||||
" does not match required "+msgFpB)
|
||||
}
|
||||
|
||||
@@ -156,11 +183,6 @@ func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
||||
func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
||||
t.Helper()
|
||||
|
||||
// Loading a manifest logs to the process-global logger, so serialize
|
||||
// with the CLI runs.
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644))
|
||||
|
||||
@@ -451,49 +473,6 @@ func TestFreshenReadErrorMessage(t *testing.T) {
|
||||
"read "+filepath.Join(root, "sub")+": is a directory")
|
||||
}
|
||||
|
||||
// TestFreshenSizeChangedMessage has freshen hash a file that has grown or
|
||||
// shrunk since the scan recorded its size.
|
||||
func TestFreshenSizeChangedMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
writeTestFile(t, fs, "/d/log.txt", "hello")
|
||||
|
||||
t.Run("grown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hasher := &freshenHasher{
|
||||
fs: fs,
|
||||
absBase: "/d",
|
||||
builder: mfer.NewBuilder(),
|
||||
}
|
||||
|
||||
err := hasher.processEntry(
|
||||
&freshenEntry{path: "log.txt", size: 3, needsHash: true})
|
||||
require.ErrorIs(t, err, errSizeMismatch)
|
||||
assert.EqualError(t, err, `size mismatch for "log.txt": `+
|
||||
`3 bytes when scanned but 5 bytes when read; `+
|
||||
`the file changed while freshen ran`)
|
||||
})
|
||||
|
||||
t.Run("shrunk", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hasher := &freshenHasher{
|
||||
fs: fs,
|
||||
absBase: "/d",
|
||||
builder: mfer.NewBuilder(),
|
||||
}
|
||||
|
||||
err := hasher.processEntry(
|
||||
&freshenEntry{path: "log.txt", size: 8, needsHash: true})
|
||||
require.ErrorIs(t, err, errSizeMismatch)
|
||||
assert.EqualError(t, err, `size mismatch for "log.txt": `+
|
||||
`8 bytes when scanned but 5 bytes when read; `+
|
||||
`the file changed while freshen ran`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestURLRequiredMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -20,11 +20,6 @@ const testCmdExport = "export"
|
||||
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
// The scan logs to the process-global logger, so serialize with the
|
||||
// CLI runs.
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
for path, content := range files {
|
||||
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
||||
|
||||
+63
-47
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
@@ -85,8 +86,7 @@ var (
|
||||
// symlink that already exists in the target directory.
|
||||
errSymlinkInPath = errors.New("symlink in path not allowed")
|
||||
// errSizeMismatch indicates a downloaded file with an unexpected
|
||||
// size, or a file freshen read at a size other than the one its scan
|
||||
// recorded.
|
||||
// size.
|
||||
errSizeMismatch = errors.New("size mismatch")
|
||||
// errHashMismatch indicates a downloaded file whose hash matches no
|
||||
// manifest hash.
|
||||
@@ -95,6 +95,9 @@ var (
|
||||
// writes another file.
|
||||
errNameClash = errors.New(
|
||||
"manifest lists a file where fetch writes another file")
|
||||
// errModeOutOfRange indicates a manifest that lists a mode with more
|
||||
// than the permission bits, such as setuid.
|
||||
errModeOutOfRange = errors.New("manifest lists a mode outside 0777")
|
||||
)
|
||||
|
||||
// DownloadProgress reports the progress of a single file download.
|
||||
@@ -122,9 +125,8 @@ type retryingClient struct {
|
||||
// to a limit that doubles each time, unless the server's Retry-After
|
||||
// header says how long to wait. Any other failure, an error from use
|
||||
// included, is returned at once and unwrapped; a non-OK status is
|
||||
// returned as errHTTPStatus. If ctx ends before a retry, the error wraps
|
||||
// both ctx's error and the last try's error. use must start over each
|
||||
// time it is called, since a retry calls it again with a new response.
|
||||
// returned as errHTTPStatus. use must start over each time it is called,
|
||||
// since a retry calls it again with a new response.
|
||||
func (c retryingClient) get(
|
||||
ctx context.Context, rawURL string, use func(*http.Response) error,
|
||||
) error {
|
||||
@@ -166,19 +168,15 @@ func (c retryingClient) get(
|
||||
}
|
||||
}
|
||||
|
||||
if !retry || attempt == fetchAttempts {
|
||||
if !retry || attempt == fetchAttempts || ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%w: %w", ctx.Err(), err)
|
||||
}
|
||||
|
||||
log.Warnf("%s: %s, retrying in %s", rawURL, err, wait.Round(time.Millisecond))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("%w: %w", ctx.Err(), err)
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
|
||||
@@ -425,20 +423,12 @@ func (mfa *CLIApp) fetchManifestOperation(
|
||||
|
||||
// fetchManifest downloads the manifest at manifestURL and parses it,
|
||||
// enforcing --require-signature if it is given and refusing a manifest
|
||||
// that lists a file where fetch writes another. It returns the manifest as
|
||||
// downloaded, to be saved once the files are in place, and the files it
|
||||
// lists.
|
||||
// that lists a file where fetch writes another or a mode outside 0777. It
|
||||
// returns the manifest as downloaded, to be saved once the files are in
|
||||
// place, and the files it lists.
|
||||
func (mfa *CLIApp) fetchManifest(
|
||||
ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string,
|
||||
) ([]byte, []*mfer.MFFilePath, error) {
|
||||
// An empty fingerprint is refused before anything is requested, with
|
||||
// the message check gives for it; any other is checked against the
|
||||
// manifest once it is downloaded.
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if cmd.IsSet(flagRequireSignature) && requiredSigner == "" {
|
||||
return nil, nil, fmt.Errorf("%w, got 0", errInvalidFingerprint)
|
||||
}
|
||||
|
||||
log.Infof("fetching manifest from %s", manifestURL)
|
||||
|
||||
// Read the whole manifest before parsing it, so that a connection
|
||||
@@ -470,8 +460,9 @@ func (mfa *CLIApp) fetchManifest(
|
||||
return nil, nil, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if requiredSigner != "" {
|
||||
err = verifyRequiredSigner(string(manifest.Signer()), requiredSigner)
|
||||
err = verifyFetchedSigner(manifestData, requiredSigner)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -484,6 +475,16 @@ func (mfa *CLIApp) fetchManifest(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// fetch sets each recorded mode on the file it writes, so a mode above
|
||||
// 0777, which could carry setuid, setgid or sticky bits, is refused
|
||||
// before any file is requested.
|
||||
for _, f := range files {
|
||||
if f.GetMode() > uint32(os.ModePerm) {
|
||||
return nil, nil, fmt.Errorf("%w: %s (%#o)",
|
||||
errModeOutOfRange, f.GetPath(), f.GetMode())
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("manifest contains %d files", len(files))
|
||||
|
||||
return manifestData, files, nil
|
||||
@@ -539,6 +540,31 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFetchedSigner enforces --require-signature on the fetched manifest
|
||||
// exactly as check does. verifyRequiredSigner takes a Checker, which loads
|
||||
// its manifest from a file, so the manifest is handed to it as a file in
|
||||
// memory.
|
||||
func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
|
||||
memFs := afero.NewMemMapFs()
|
||||
manifestPath := "/" + defaultManifestName
|
||||
|
||||
err := afero.WriteFile(memFs, manifestPath, manifestData, filePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: manifestPath,
|
||||
BasePath: "/",
|
||||
Fs: memFs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load manifest: %w", err)
|
||||
}
|
||||
|
||||
return verifyRequiredSigner(chk, requiredSigner)
|
||||
}
|
||||
|
||||
// saveManifest writes the fetched manifest into dest under the default
|
||||
// manifest name, the way fetch writes every file: to a new temp file that
|
||||
// is then renamed into place.
|
||||
@@ -653,26 +679,21 @@ func resolveManifestURL(inputURL string) (string, error) {
|
||||
return parsed.JoinPath(defaultManifestName).String(), nil
|
||||
}
|
||||
|
||||
// progressWriter wraps an io.Writer and reports progress to a channel,
|
||||
// at most once per second and once more when the last byte is written.
|
||||
// progressWriter wraps an io.Writer and reports progress to a channel.
|
||||
type progressWriter struct {
|
||||
w io.Writer
|
||||
path string
|
||||
total int64
|
||||
written int64
|
||||
startTime time.Time
|
||||
lastProgress time.Time
|
||||
progress chan<- DownloadProgress
|
||||
w io.Writer
|
||||
path string
|
||||
total int64
|
||||
written int64
|
||||
startTime time.Time
|
||||
progress chan<- DownloadProgress
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.w.Write(p)
|
||||
|
||||
pw.written += int64(n)
|
||||
|
||||
isLast := pw.written == pw.total
|
||||
if pw.progress != nil &&
|
||||
(isLast || time.Since(pw.lastProgress) >= time.Second) {
|
||||
if pw.progress != nil {
|
||||
var (
|
||||
bytesPerSec float64
|
||||
eta time.Duration
|
||||
@@ -694,8 +715,6 @@ func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
BytesPerSec: bytesPerSec,
|
||||
ETA: eta,
|
||||
})
|
||||
|
||||
pw.lastProgress = time.Now()
|
||||
}
|
||||
|
||||
return n, err
|
||||
@@ -880,16 +899,13 @@ func saveResponse(
|
||||
// Set up hash computation
|
||||
h := sha256.New()
|
||||
|
||||
// Create progress-reporting writer that also computes hash. The first
|
||||
// update waits a second, as gen's and check's do.
|
||||
start := time.Now()
|
||||
// Create progress-reporting writer that also computes hash
|
||||
pw := &progressWriter{
|
||||
w: io.MultiWriter(out, h),
|
||||
path: localPath,
|
||||
total: totalBytes,
|
||||
startTime: start,
|
||||
lastProgress: start,
|
||||
progress: progress,
|
||||
w: io.MultiWriter(out, h),
|
||||
path: localPath,
|
||||
total: totalBytes,
|
||||
startTime: time.Now(),
|
||||
progress: progress,
|
||||
}
|
||||
|
||||
// Copy content while hashing and reporting progress. One byte past
|
||||
|
||||
+110
-108
@@ -19,12 +19,15 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"uuid"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/multiformats/go-multihash"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -168,11 +171,6 @@ func TestResolveManifestURL(t *testing.T) {
|
||||
func scanToManifest(t *testing.T, sourceFs afero.Fs) []byte {
|
||||
t.Helper()
|
||||
|
||||
// The scan logs to the process-global logger, so serialize with the
|
||||
// CLI runs.
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: sourceFs})
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
|
||||
@@ -316,11 +314,8 @@ func TestFetchFromHTTP(t *testing.T) {
|
||||
// Change to a fresh destination directory for the test
|
||||
destDir := chdirTemp(t)
|
||||
|
||||
// Parse the manifest to get file entries. Loading a manifest logs to
|
||||
// the process-global logger.
|
||||
runMu.Lock()
|
||||
// Parse the manifest to get file entries
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
runMu.Unlock()
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
@@ -370,10 +365,7 @@ func TestFetchHashMismatch(t *testing.T) {
|
||||
// Generate and parse manifest
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
// Loading a manifest logs to the process-global logger.
|
||||
runMu.Lock()
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
runMu.Unlock()
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
@@ -419,10 +411,7 @@ func TestFetchSizeMismatch(t *testing.T) {
|
||||
// Generate and parse manifest
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
// Loading a manifest logs to the process-global logger.
|
||||
runMu.Lock()
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
runMu.Unlock()
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
@@ -486,17 +475,18 @@ func TestFetchStopsReadingFilePastListedSize(t *testing.T) {
|
||||
|
||||
// TestManifestDownloadStopsAtLimit serves a manifest that never ends to
|
||||
// fetch and to check, with the most they download of a manifest lowered
|
||||
// to 64 KiB. Each must stop reading at that limit and fail with an error
|
||||
// naming it.
|
||||
// to 64 KiB. Each must stop reading at that limit, fail with an error
|
||||
// naming it and leave no temp file.
|
||||
func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.Copy(w, zeros{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmpDir)
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10}
|
||||
|
||||
fetch := mfa.fetchCommand()
|
||||
@@ -516,23 +506,24 @@ func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
||||
"download manifest: file exceeds maximum allowed size of 65536 bytes",
|
||||
cmd.Name)
|
||||
}
|
||||
|
||||
leftover, err := os.ReadDir(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftover)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchProgress(t *testing.T) {
|
||||
// Create source filesystem with a larger test file
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
// Content large enough that io.Copy writes it in at least four chunks
|
||||
// Create content large enough to trigger multiple progress updates
|
||||
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
||||
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
|
||||
|
||||
// Generate and parse manifest
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
// Loading a manifest logs to the process-global logger.
|
||||
runMu.Lock()
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
runMu.Unlock()
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
@@ -568,11 +559,8 @@ func TestFetchProgress(t *testing.T) {
|
||||
}()
|
||||
|
||||
// Download
|
||||
start := time.Now()
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/large.txt", ".", "large.txt", files[0], progress)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
close(progress)
|
||||
<-done
|
||||
|
||||
@@ -581,12 +569,6 @@ func TestFetchProgress(t *testing.T) {
|
||||
// Verify we got progress updates
|
||||
assert.NotEmpty(t, progressUpdates, "should have received progress updates")
|
||||
|
||||
// At most one update per second, plus the one reporting the file
|
||||
// complete: a download that takes under a second sends two at most,
|
||||
// however many chunks it writes.
|
||||
assert.LessOrEqual(t, len(progressUpdates), 2+int(elapsed/time.Second),
|
||||
"one progress update per second at most, plus the last")
|
||||
|
||||
// Verify final progress shows complete
|
||||
if len(progressUpdates) > 0 {
|
||||
last := progressUpdates[len(progressUpdates)-1]
|
||||
@@ -889,59 +871,23 @@ func TestGetHonorsRetryAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// instant503Transport answers every request with a 503 at once, so the
|
||||
// 503 is in hand before any deadline can pass.
|
||||
type instant503Transport struct{}
|
||||
|
||||
func (instant503Transport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestGetStopsWaitingWhenCanceled answers every request with a 503 and
|
||||
// sets an hour's wait before each retry, then lets the context expire
|
||||
// during that wait. get returning at all shows it stopped waiting; it must
|
||||
// return the deadline error and keep the 503 with it.
|
||||
// TestGetStopsWaitingWhenCanceled gives get a server that always answers
|
||||
// 503 and an hour's wait before each retry, then lets the context expire
|
||||
// during that wait. get returning at all shows it stopped waiting.
|
||||
func TestGetStopsWaitingWhenCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := testClient()
|
||||
client.client.Transport = instant503Transport{}
|
||||
client.firstDelay = time.Hour
|
||||
|
||||
err := getNothing(client, "http://example.invalid/", 50*time.Millisecond)
|
||||
err := getNothing(client, server.URL, 50*time.Millisecond)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.ErrorIs(t, err, errHTTPStatus)
|
||||
}
|
||||
|
||||
// late503Transport answers every request with a 503, but only once the
|
||||
// request's context has ended. A real server cannot stand in for it: the
|
||||
// client drops a response still on its way when the context ends.
|
||||
type late503Transport struct{}
|
||||
|
||||
func (late503Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestGetStopsWhenContextEndsDuringRequest lets the context expire while
|
||||
// the first request is still waiting for its 503. get must return the
|
||||
// deadline error and keep the 503 with it.
|
||||
func TestGetStopsWhenContextEndsDuringRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := testClient()
|
||||
client.client.Transport = late503Transport{}
|
||||
|
||||
err := getNothing(client, "http://example.invalid/", 50*time.Millisecond)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.ErrorIs(t, err, errHTTPStatus)
|
||||
}
|
||||
|
||||
// TestDownloadFileRetriesToSuccess serves a file that fails twice before
|
||||
@@ -951,12 +897,9 @@ func TestGetStopsWhenContextEndsDuringRequest(t *testing.T) {
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
||||
content := []byte("the whole file, every byte of it")
|
||||
manifestData := manifestOf(t, map[string][]byte{testFileTxt: content})
|
||||
|
||||
// Loading a manifest logs to the process-global logger.
|
||||
runMu.Lock()
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
runMu.Unlock()
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(
|
||||
manifestOf(t, map[string][]byte{testFileTxt: content})))
|
||||
require.NoError(t, err)
|
||||
|
||||
var requests atomic.Int32
|
||||
@@ -979,11 +922,8 @@ func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
// downloadFile logs each retry to the process-global logger.
|
||||
err = runLocked(func() error {
|
||||
return downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
|
||||
})
|
||||
err = downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(3), requests.Load())
|
||||
|
||||
@@ -1303,10 +1243,10 @@ func TestFetchRequireSignature(t *testing.T) {
|
||||
|
||||
// TestFetchRefusesListedManifestName fetches manifests that list, at the
|
||||
// top of the tree, the name fetch saves the manifest under or that name's
|
||||
// temp file: as a file, as a directory and in capitals. Saving the
|
||||
// manifest would replace or remove what is listed there, so fetch must
|
||||
// refuse the manifest before it creates the destination or requests any
|
||||
// file.
|
||||
// temp file: as a file, as a directory, in capitals, and with a leading
|
||||
// "./". Saving the manifest would replace or remove what is listed there,
|
||||
// so fetch must refuse the manifest before it creates the destination or
|
||||
// requests any file.
|
||||
func TestFetchRefusesListedManifestName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1315,6 +1255,7 @@ func TestFetchRefusesListedManifestName(t *testing.T) {
|
||||
tempPathFor(defaultManifestName),
|
||||
defaultManifestName + "/" + testFileTxt,
|
||||
"INDEX.MF",
|
||||
"./" + defaultManifestName,
|
||||
} {
|
||||
t.Run(listed, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1390,9 +1331,9 @@ func TestFetchRefusesListedTempName(t *testing.T) {
|
||||
// directory, one of them under a spelling the manifest does not list,
|
||||
// and check reports that file as not in the manifest. So fetch must
|
||||
// refuse each on every filesystem before it creates the destination or
|
||||
// requests any file. A file and a directory with the same name are
|
||||
// refused the same way. A manifest whose names differ in more than
|
||||
// letter case is fetched in full.
|
||||
// requests any file. A file and a directory with the same name, and a
|
||||
// file listed twice, are refused the same way. A manifest whose names
|
||||
// differ in more than letter case is fetched in full.
|
||||
func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1402,6 +1343,7 @@ func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
||||
{"Dir", "dir/x", "dir/x (the file Dir)"},
|
||||
{"Dir/a.txt", "dir/b.txt", "dir/b.txt (the directory Dir)"},
|
||||
{"dir", "dir/x", "dir/x (the file dir)"},
|
||||
{"./x.txt", "x.txt", "x.txt (the file ./x.txt)"},
|
||||
} {
|
||||
t.Run(tc.first+" and "+tc.second, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1462,18 +1404,14 @@ func TestFetchSetsRecordedMode(t *testing.T) {
|
||||
require.NoError(t, afero.WriteFile(sourceFs, "/"+p, content, modes[p]))
|
||||
}
|
||||
|
||||
scanner := mfer.NewScannerWithOptions(&mfer.ScannerOptions{
|
||||
Fs: sourceFs,
|
||||
IncludePermissions: true,
|
||||
})
|
||||
require.NoError(t, scanner.EnumerateFS(sourceFs, "/", nil))
|
||||
|
||||
var manifest bytes.Buffer
|
||||
|
||||
// The scan logs to the process-global logger.
|
||||
require.NoError(t, runLocked(func() error {
|
||||
scanner := mfer.NewScannerWithOptions(&mfer.ScannerOptions{
|
||||
Fs: sourceFs,
|
||||
IncludePermissions: true,
|
||||
})
|
||||
require.NoError(t, scanner.EnumerateFS(sourceFs, "/", nil))
|
||||
|
||||
return scanner.ToManifest(context.Background(), &manifest, nil)
|
||||
}))
|
||||
require.NoError(t, scanner.ToManifest(context.Background(), &manifest, nil))
|
||||
|
||||
tree := fetchTestHandler(manifest.Bytes(), files)
|
||||
|
||||
@@ -1532,9 +1470,27 @@ func TestFetchSetsRecordedMode(t *testing.T) {
|
||||
[]string{"/" + defaultManifestName, "/" + testFileTxt}, requested)
|
||||
}
|
||||
|
||||
// TestFetchRefusesModeOutsidePermissionBits fetches manifests that record
|
||||
// a mode with the setuid bit, once as Unix writes it (04755) and once as
|
||||
// Go keeps it in os.FileMode. fetch must refuse both before it creates the
|
||||
// destination or requests any file.
|
||||
func TestFetchRefusesModeOutsidePermissionBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := map[string][]byte{testFileTxt: []byte("a file")}
|
||||
|
||||
assertFetchRefused(t, manifestWithMode(t, testFileTxt, files[testFileTxt], 0o4755),
|
||||
files, "manifest lists a mode outside 0777: file.txt (04755)")
|
||||
|
||||
assertFetchRefused(t,
|
||||
manifestWithMode(t, testFileTxt, files[testFileTxt],
|
||||
uint32(os.ModeSetuid|0o755)),
|
||||
files, "manifest lists a mode outside 0777: file.txt (040000755)")
|
||||
}
|
||||
|
||||
// TestDownloadFileSetsOnlyPermissionBits downloads a file whose entry
|
||||
// records mode 0755 together with setuid, setgid or sticky, as Go keeps
|
||||
// them in os.FileMode. Loading a manifest refuses such an entry; called
|
||||
// them in os.FileMode. fetchManifest would refuse such an entry; called
|
||||
// directly, downloadFile must still set only the permission bits.
|
||||
func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1571,8 +1527,54 @@ func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// manifestWithMode returns a manifest listing one file, path, with content
|
||||
// and the given recorded mode. It is assembled by hand, since the builder
|
||||
// never records a mode outside 0777.
|
||||
func manifestWithMode(t *testing.T, path string, content []byte, mode uint32) []byte {
|
||||
t.Helper()
|
||||
|
||||
digest := sha256.Sum256(content)
|
||||
hash, err := multihash.Encode(digest[:], multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := uuid.NewV4()
|
||||
|
||||
inner, err := proto.Marshal(&mfer.MFFile{
|
||||
Version: mfer.MFFile_VERSION_ONE,
|
||||
Files: []*mfer.MFFilePath{{
|
||||
Path: path,
|
||||
Size: int64(len(content)),
|
||||
Hashes: []*mfer.MFFileChecksum{{MultiHash: hash}},
|
||||
Mode: mode,
|
||||
}},
|
||||
Uuid: id[:],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
encoder, err := zstd.NewWriter(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
compressed := encoder.EncodeAll(inner, nil)
|
||||
require.NoError(t, encoder.Close())
|
||||
|
||||
sum := sha256.Sum256(compressed)
|
||||
|
||||
outer, err := proto.Marshal(&mfer.MFFileOuter{
|
||||
Version: mfer.MFFileOuter_VERSION_ONE,
|
||||
CompressionType: mfer.MFFileOuter_COMPRESSION_ZSTD,
|
||||
Size: int64(len(inner)),
|
||||
Sha256: sum[:],
|
||||
Uuid: id[:],
|
||||
InnerMessage: compressed,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return append([]byte(mfer.MAGIC), outer...)
|
||||
}
|
||||
|
||||
// builtManifest returns a manifest of files, built directly rather than
|
||||
// scanned, since a scan lists no hidden files.
|
||||
// scanned, since a scan lists no hidden files and never a path starting
|
||||
// with "./".
|
||||
func builtManifest(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+30
-79
@@ -33,15 +33,11 @@ var (
|
||||
// modification time where one is required to carry it forward unchanged.
|
||||
errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
||||
|
||||
// errEntryMissingHash indicates a manifest entry that carries no
|
||||
// SHA-256 hash to carry forward.
|
||||
errEntryMissingHash = errors.New("manifest entry has no SHA-256 hash")
|
||||
// errEntryMissingHash indicates a manifest entry that carries no hash
|
||||
// to carry forward.
|
||||
errEntryMissingHash = errors.New("manifest entry has no hash")
|
||||
)
|
||||
|
||||
// errSignKeyNeeded indicates a signed manifest given to freshen with no
|
||||
// key to sign the rewritten one.
|
||||
var errSignKeyNeeded = errors.New("give --sign-key to keep it signed")
|
||||
|
||||
// FreshenStatus contains progress information for the freshen operation.
|
||||
type FreshenStatus struct {
|
||||
Phase string // "scan" or "hash"
|
||||
@@ -315,16 +311,6 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// The scan recorded the size; a file that grew or shrank since would
|
||||
// get an entry whose size and hash describe different contents.
|
||||
if bytesRead != e.size {
|
||||
return fmt.Errorf(
|
||||
"%w for %q: %d bytes when scanned but %d bytes when read; "+
|
||||
"the file changed while freshen ran",
|
||||
errSizeMismatch, e.path, e.size, bytesRead,
|
||||
)
|
||||
}
|
||||
|
||||
h.hashedBytes += bytesRead
|
||||
h.hashedFiles++
|
||||
|
||||
@@ -365,39 +351,24 @@ func writeFreshenedManifest(
|
||||
}
|
||||
|
||||
// newFreshenBuilder constructs the manifest builder configured from CLI
|
||||
// flags, and returns the fingerprint of the --sign-key key, or "" when
|
||||
// none is given. A --sign-key given with an empty value is refused.
|
||||
func (mfa *CLIApp) newFreshenBuilder(
|
||||
cmd *cli.Command,
|
||||
) (*mfer.Builder, string, error) {
|
||||
// flags.
|
||||
func (mfa *CLIApp) newFreshenBuilder(cmd *cli.Command) (*mfer.Builder, error) {
|
||||
builder := mfer.NewBuilder()
|
||||
if cmd.Bool("include-timestamps") {
|
||||
builder.SetIncludeTimestamps(true)
|
||||
}
|
||||
|
||||
var signKeyFingerprint string
|
||||
|
||||
// Set up signing options if sign-key is provided
|
||||
if cmd.IsSet("sign-key") {
|
||||
signKey := cmd.String("sign-key")
|
||||
if signKey == "" {
|
||||
return nil, "", errEmptySignKey
|
||||
}
|
||||
|
||||
if signKey := cmd.String("sign-key"); signKey != "" {
|
||||
signing, err := mfa.signingOptions(signKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
signKeyFingerprint, err = mfer.SecretKeyFingerprint(signing.SecretKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%s: %w", signKey, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
builder.SetSigningOptions(signing)
|
||||
}
|
||||
|
||||
return builder, signKeyFingerprint, nil
|
||||
return builder, nil
|
||||
}
|
||||
|
||||
// freshenScan runs the scan phase against the loaded manifest entries
|
||||
@@ -509,19 +480,11 @@ func runFreshenHash(
|
||||
return nil
|
||||
}
|
||||
|
||||
// existingManifest is what freshen uses of the manifest it rewrites.
|
||||
type existingManifest struct {
|
||||
entriesByPath map[string]*mfer.MFFilePath
|
||||
signer string // fingerprint of the key it is signed with, "" if unsigned
|
||||
hasCreatedAt bool
|
||||
}
|
||||
|
||||
// loadExistingManifest loads the manifest and indexes its file entries
|
||||
// by path. It refuses a signed manifest when no --sign-key is given, as
|
||||
// the rewritten manifest would be unsigned.
|
||||
func (mfa *CLIApp) loadExistingManifest(
|
||||
cmd *cli.Command, manifestPath string,
|
||||
) (*existingManifest, error) {
|
||||
// loadExistingEntries loads the manifest and indexes its file entries
|
||||
// by path.
|
||||
func (mfa *CLIApp) loadExistingEntries(
|
||||
manifestPath string,
|
||||
) (map[string]*mfer.MFFilePath, error) {
|
||||
log.Infof("loading manifest from %s", manifestPath)
|
||||
|
||||
// Load existing manifest
|
||||
@@ -533,10 +496,6 @@ func (mfa *CLIApp) loadExistingManifest(
|
||||
return nil, fmt.Errorf("load manifest: %w", err)
|
||||
}
|
||||
|
||||
if signer := manifest.Signer(); signer != nil && cmd.String("sign-key") == "" {
|
||||
return nil, fmt.Errorf("manifest is signed by %s: %w", signer, errSignKeyNeeded)
|
||||
}
|
||||
|
||||
existingFiles := manifest.Files()
|
||||
log.Infof("manifest contains %d files", len(existingFiles))
|
||||
|
||||
@@ -546,11 +505,7 @@ func (mfa *CLIApp) loadExistingManifest(
|
||||
existingByPath[f.GetPath()] = f
|
||||
}
|
||||
|
||||
return &existingManifest{
|
||||
entriesByPath: existingByPath,
|
||||
signer: string(manifest.Signer()),
|
||||
hasCreatedAt: manifest.CreatedAt() != nil,
|
||||
}, nil
|
||||
return existingByPath, nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) freshenManifestOperation(
|
||||
@@ -558,18 +513,20 @@ func (mfa *CLIApp) freshenManifestOperation(
|
||||
) error {
|
||||
log.Debug("freshenManifestOperation()")
|
||||
|
||||
showProgress := cmd.Bool("progress")
|
||||
|
||||
// Find manifest file
|
||||
manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
builder, signKeyFingerprint, err := mfa.newFreshenBuilder(cmd)
|
||||
builder, err := mfa.newFreshenBuilder(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := mfa.loadExistingManifest(cmd, manifestPath)
|
||||
existingByPath, err := mfa.loadExistingEntries(manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -581,7 +538,7 @@ func (mfa *CLIApp) freshenManifestOperation(
|
||||
|
||||
// Phase 1: Scan filesystem
|
||||
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
||||
existing.entriesByPath)
|
||||
existingByPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -598,7 +555,7 @@ func (mfa *CLIApp) freshenManifestOperation(
|
||||
hasher := &freshenHasher{
|
||||
fs: mfa.Fs,
|
||||
absBase: absBase,
|
||||
showProgress: cmd.Bool("progress"),
|
||||
showProgress: showProgress,
|
||||
totalHashBytes: totalHashBytes,
|
||||
filesToHash: filesToHash,
|
||||
startHash: time.Now(),
|
||||
@@ -614,14 +571,8 @@ func (mfa *CLIApp) freshenManifestOperation(
|
||||
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
|
||||
scanner.unchanged, scanner.changed, scanner.added, removed)
|
||||
|
||||
// Skip writing only when the manifest would come out the same: nothing
|
||||
// in the tree changed, and the manifest already holds what --sign-key,
|
||||
// --include-timestamps and --include-permissions ask for. The scan
|
||||
// counts a file whose recorded mode is not what --include-permissions
|
||||
// asks for as changed.
|
||||
if scanner.changed == 0 && scanner.added == 0 && removed == 0 &&
|
||||
existing.signer == signKeyFingerprint &&
|
||||
existing.hasCreatedAt == cmd.Bool("include-timestamps") {
|
||||
// Skip writing if nothing changed
|
||||
if scanner.changed == 0 && scanner.added == 0 && removed == 0 {
|
||||
log.Infof("manifest unchanged, skipping write")
|
||||
|
||||
return nil
|
||||
@@ -699,26 +650,26 @@ func addFileToBuilder(
|
||||
// Entries reach this path only when recordEntry classified them as
|
||||
// unchanged, which requires a recorded mtime, so an absent mtime here is
|
||||
// an error rather than something to paper over with the Unix epoch.
|
||||
// Only the entry's SHA-256 hash is carried over, wherever the entry lists
|
||||
// it, since check and fetch compare with no other. Loading refuses an
|
||||
// entry with no SHA-256 hash; one that got here anyway is an error too,
|
||||
// not a file to leave out of the manifest.
|
||||
// Loading refuses an entry with no hash; one that got here anyway is an
|
||||
// error too, not a file to leave out of the manifest.
|
||||
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
||||
mtime, ok := entryMtime(entry)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", errEntryMissingMtime, entry.GetPath())
|
||||
}
|
||||
|
||||
hash := mfer.SHA256Multihash(entry)
|
||||
if hash == nil {
|
||||
if len(entry.GetHashes()) == 0 {
|
||||
return fmt.Errorf("%w: %s", errEntryMissingHash, entry.GetPath())
|
||||
}
|
||||
|
||||
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
||||
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
|
||||
fs.FileMode(entry.GetMode()), hash)
|
||||
fs.FileMode(entry.GetMode()), entry.GetHashes()[0].GetMultiHash())
|
||||
if err != nil {
|
||||
return fmt.Errorf("manifest entry %s: %w", entry.GetPath(), err)
|
||||
return fmt.Errorf(
|
||||
"manifest entry %s: %w (regenerate the manifest with mfer generate)",
|
||||
entry.GetPath(), err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+29
-173
@@ -2,10 +2,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -73,11 +70,6 @@ func runFreshen(t *testing.T, fs afero.Fs, root, manifestPath string) {
|
||||
func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
||||
t.Helper()
|
||||
|
||||
// Loading a manifest logs to the process-global logger, so serialize
|
||||
// with the CLI runs.
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: path,
|
||||
Fs: fs,
|
||||
@@ -87,23 +79,6 @@ func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
||||
return manifest.Files()
|
||||
}
|
||||
|
||||
// manifestCreatedAt returns the createdAt timestamp of the manifest at
|
||||
// path, or nil if it has none.
|
||||
func manifestCreatedAt(t *testing.T, fs afero.Fs, path string) *mfer.Timestamp {
|
||||
t.Helper()
|
||||
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||
Path: path,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return manifest.CreatedAt()
|
||||
}
|
||||
|
||||
// TestFreshenUnchanged freshens a tree that has not changed since gen
|
||||
// made its manifest: the manifest must list the same entries as before.
|
||||
func TestFreshenUnchanged(t *testing.T) {
|
||||
@@ -161,41 +136,6 @@ func TestFreshenRecordsModeOnlyWhenAsked(t *testing.T) {
|
||||
recordedModes())
|
||||
}
|
||||
|
||||
// TestFreshenIncludeTimestampsOnUnchangedTree runs freshen
|
||||
// --include-timestamps on a tree that has not changed since gen made its
|
||||
// manifest, which has no createdAt timestamp. freshen must rewrite the
|
||||
// manifest with one. Run again with the flag it must leave the manifest as
|
||||
// it is; run without it, it must rewrite the manifest without one.
|
||||
func TestFreshenIncludeTimestampsOnUnchangedTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
root, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "hello"})
|
||||
|
||||
freshenWithTimestamps := func() {
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFreshen, "-q", "--include-timestamps",
|
||||
testFlagBase, root, manifestPath,
|
||||
}, fs)
|
||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
}
|
||||
|
||||
freshenWithTimestamps()
|
||||
assert.NotNil(t, manifestCreatedAt(t, fs, manifestPath))
|
||||
|
||||
before, err := afero.ReadFile(fs, manifestPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
freshenWithTimestamps()
|
||||
|
||||
after, err := afero.ReadFile(fs, manifestPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before, after, "manifest that has a createdAt was rewritten")
|
||||
|
||||
runFreshen(t, fs, root, manifestPath)
|
||||
assert.Nil(t, manifestCreatedAt(t, fs, manifestPath))
|
||||
}
|
||||
|
||||
// assertManifestLists asserts that the manifest at manifestPath lists
|
||||
// exactly the files in want, each with the size and SHA-256 hash of its
|
||||
// content in want and the mtime of the file of that name under root.
|
||||
@@ -303,58 +243,6 @@ func TestFreshenWithChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// growingFileFs appends to one file each time it is opened, as a program
|
||||
// still writing to the file would between freshen's scan and its hash.
|
||||
type growingFileFs struct {
|
||||
afero.Fs
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
//nolint:ireturn // Open must return afero.File to satisfy afero.Fs.
|
||||
func (f growingFileFs) Open(name string) (afero.File, error) {
|
||||
if name == f.path {
|
||||
file, err := f.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = file.WriteString(" world")
|
||||
_ = file.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// TestFreshenFileChangedSinceScan has a new file grow after freshen's scan
|
||||
// recorded its size and before freshen hashes it: freshen must fail and
|
||||
// leave the manifest as it was.
|
||||
func TestFreshenFileChangedSinceScan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
osFs := afero.NewOsFs()
|
||||
root, manifestPath := setupFreshenDir(t, osFs,
|
||||
map[string]string{testFileTxt: "content"})
|
||||
grown := filepath.Join(root, "log.txt")
|
||||
writeTestFile(t, osFs, grown, "hello")
|
||||
|
||||
before, err := afero.ReadFile(osFs, manifestPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdFreshen, "-q", testFlagBase, root, manifestPath,
|
||||
}, growingFileFs{Fs: osFs, path: grown})
|
||||
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||
|
||||
after, err := afero.ReadFile(osFs, manifestPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before, after, "manifest changed")
|
||||
}
|
||||
|
||||
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
|
||||
// subdirectory of the tree it lists: the manifest is not listed, while an
|
||||
// ordinary file of the same name at the top of the tree is. The manifest
|
||||
@@ -548,11 +436,7 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
||||
s := &freshenScanner{
|
||||
existingByPath: map[string]*mfer.MFFilePath{relPath: tc.entry},
|
||||
}
|
||||
|
||||
// recordEntry logs to the process-global logger.
|
||||
runMu.Lock()
|
||||
s.recordEntry(relPath, info)
|
||||
runMu.Unlock()
|
||||
|
||||
require.Len(t, s.entries, 1)
|
||||
assert.Equal(t, tc.needsHash, s.entries[0].needsHash)
|
||||
@@ -587,76 +471,48 @@ func TestFreshenAddExistingRejectsMissingMtime(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "file1.txt")
|
||||
}
|
||||
|
||||
// TestFreshenAddExistingRejectsMissingHash pins that an entry with no
|
||||
// SHA-256 hash, whether it lists no hash or only a SHA-1 one, is an error
|
||||
// rather than a file left out of the rebuilt manifest.
|
||||
// TestFreshenAddExistingRejectsMissingHash pins that an entry with no hash
|
||||
// is an error rather than a file left out of the rebuilt manifest.
|
||||
func TestFreshenAddExistingRejectsMissingHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := mfer.NewBuilder()
|
||||
entry := &mfer.MFFilePath{
|
||||
Path: "data.bin",
|
||||
Size: 8,
|
||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||
}
|
||||
|
||||
err := addExistingToBuilder(b, entry)
|
||||
require.ErrorIs(t, err, errEntryMissingHash)
|
||||
assert.Contains(t, err.Error(), "data.bin")
|
||||
assert.Zero(t, b.FileCount())
|
||||
}
|
||||
|
||||
// TestFreshenAddExistingRejectsShortHash pins that an existing manifest
|
||||
// entry whose hash the builder refuses is reported as a problem with the
|
||||
// manifest, with the command that fixes it.
|
||||
func TestFreshenAddExistingRejectsShortHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hashes []*mfer.MFFileChecksum
|
||||
}{
|
||||
{"no hash", nil},
|
||||
{"SHA-1 hash only", []*mfer.MFFileChecksum{{MultiHash: sha1Hash}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := mfer.NewBuilder()
|
||||
entry := &mfer.MFFilePath{
|
||||
Path: "data.bin",
|
||||
Size: 8,
|
||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||
Hashes: tt.hashes,
|
||||
}
|
||||
|
||||
err := addExistingToBuilder(b, entry)
|
||||
require.ErrorIs(t, err, errEntryMissingHash)
|
||||
assert.Contains(t, err.Error(), "data.bin")
|
||||
assert.Zero(t, b.FileCount())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreshenAddExistingKeepsSHA256Hash pins that an unchanged entry that
|
||||
// lists another hash before its SHA-256 hash is carried over with the
|
||||
// SHA-256 hash, the one check and fetch compare with, and nothing else.
|
||||
func TestFreshenAddExistingKeepsSHA256Hash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sha512Hash, err := multihash.Encode(make([]byte, sha512.Size), multihash.SHA2_512)
|
||||
require.NoError(t, err)
|
||||
|
||||
sha256Hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
b := mfer.NewBuilder()
|
||||
entry := &mfer.MFFilePath{
|
||||
Path: "kept.txt",
|
||||
Size: 6,
|
||||
Path: "old.txt",
|
||||
Size: 8,
|
||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||
Hashes: []*mfer.MFFileChecksum{
|
||||
{MultiHash: sha512Hash},
|
||||
{MultiHash: sha256Hash},
|
||||
{MultiHash: sha1Hash},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, addExistingToBuilder(b, entry))
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, b.Build(context.Background(), &buf))
|
||||
|
||||
m, err := mfer.NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, m.Files(), 1)
|
||||
require.Len(t, m.Files()[0].GetHashes(), 1)
|
||||
assert.Equal(t, sha256Hash, m.Files()[0].GetHashes()[0].GetMultiHash())
|
||||
err = addExistingToBuilder(b, entry)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "manifest entry old.txt")
|
||||
assert.Contains(t, err.Error(), "mfer generate")
|
||||
assert.Zero(t, b.FileCount())
|
||||
}
|
||||
|
||||
// TestEntryMtime pins the presence semantics the callers depend on.
|
||||
|
||||
+2
-10
@@ -29,8 +29,6 @@ var (
|
||||
"already exists (use --force to overwrite)")
|
||||
// errEmptyOutput indicates --output given with an empty value.
|
||||
errEmptyOutput = errors.New("--output must not be empty")
|
||||
// errEmptySignKey indicates --sign-key given with an empty value.
|
||||
errEmptySignKey = errors.New("--sign-key must not be empty")
|
||||
)
|
||||
|
||||
// reportEnumProgress renders enumeration progress until the channel
|
||||
@@ -123,8 +121,7 @@ func (mfa *CLIApp) outputPath(cmd *cli.Command) (string, error) {
|
||||
}
|
||||
|
||||
// buildScannerOptions constructs scanner options from the CLI flags and
|
||||
// the path the manifest is written to. A --sign-key given with an empty
|
||||
// value is refused.
|
||||
// the path the manifest is written to.
|
||||
func (mfa *CLIApp) buildScannerOptions(
|
||||
cmd *cli.Command, output string,
|
||||
) (*mfer.ScannerOptions, error) {
|
||||
@@ -147,12 +144,7 @@ func (mfa *CLIApp) buildScannerOptions(
|
||||
}
|
||||
|
||||
// Set up signing options if sign-key is provided
|
||||
if cmd.IsSet("sign-key") {
|
||||
signKey := cmd.String("sign-key")
|
||||
if signKey == "" {
|
||||
return nil, errEmptySignKey
|
||||
}
|
||||
|
||||
if signKey := cmd.String("sign-key"); signKey != "" {
|
||||
signing, err := mfa.signingOptions(signKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+33
-67
@@ -6,12 +6,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/urfave/cli/v3"
|
||||
"sneak.berlin/go/mfer/internal/log"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
// Command and flag names shared across command definitions and tests.
|
||||
@@ -58,6 +58,7 @@ var errUnexpectedArgument = errors.New("unexpected argument")
|
||||
//nolint:revive // established name used throughout the codebase and tests
|
||||
type CLIApp struct {
|
||||
appname string
|
||||
version string
|
||||
gitrev string
|
||||
startupTime time.Time
|
||||
exitCode int
|
||||
@@ -86,26 +87,13 @@ const banner = `
|
||||
\ \:\ \ \:\ \ \::/ \ \:\
|
||||
\__\/ \__\/ \__\/ \__\/`
|
||||
|
||||
// VersionString returns the version mfer reports: the revision the build
|
||||
// stamped into main.Gitrev or, when it stamped none, as with `go install`,
|
||||
// the main module's version from the binary's build information.
|
||||
// VersionString returns the version and git revision formatted for display.
|
||||
func (mfa *CLIApp) VersionString() string {
|
||||
if mfa.gitrev != "" {
|
||||
return mfa.gitrev
|
||||
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
||||
}
|
||||
|
||||
return moduleVersion(debug.ReadBuildInfo())
|
||||
}
|
||||
|
||||
// moduleVersion returns the main module's version from the binary's build
|
||||
// information, or "unknown" when the binary has none or it holds no version,
|
||||
// as in a build with GO111MODULE=off.
|
||||
func moduleVersion(info *debug.BuildInfo, ok bool) string {
|
||||
if !ok || info.Main.Version == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return info.Main.Version
|
||||
return mfer.Version
|
||||
}
|
||||
|
||||
// printVersion writes the version line shared by the --version flag and the
|
||||
@@ -118,7 +106,8 @@ func (mfa *CLIApp) printBanner() {
|
||||
if log.GetLevel() <= log.InfoLevel {
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
||||
_, _ = fmt.Fprintf(mfa.Stdout,
|
||||
" mfer by @sneak: %s\n", mfa.VersionString())
|
||||
" mfer by @sneak: v%s released %s\n",
|
||||
mfer.Version, mfer.ReleaseDate)
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||
}
|
||||
}
|
||||
@@ -179,17 +168,6 @@ func refuseExtraArgs(_ context.Context, cmd *cli.Command) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// showCommandHelp is the CommandNotFound of the commands that take a path or
|
||||
// URL. Those commands set HideHelpCommand, so that a path or URL named help or
|
||||
// h is an argument rather than urfave/cli's help command; each sets it itself,
|
||||
// because on the root it would also remove `mfer help`. With --help,
|
||||
// urfave/cli looks the command's first argument up as a help topic, so
|
||||
// `check DIR --help` asks for a topic DIR and finds none; this prints the
|
||||
// command's own help instead, as `check --help` does.
|
||||
func showCommandHelp(ctx context.Context, cmd *cli.Command, _ string) {
|
||||
_ = cli.ShowCommandHelp(ctx, cmd.Root(), cmd.Name)
|
||||
}
|
||||
|
||||
// requireSignatureFlag returns the --require-signature flag taken by the
|
||||
// check and fetch subcommands.
|
||||
func requireSignatureFlag() *cli.StringFlag {
|
||||
@@ -213,12 +191,10 @@ func includePermissionsFlag() *cli.BoolFlag {
|
||||
|
||||
func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdGenerate,
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate manifest file",
|
||||
ArgsUsage: "[path ...]",
|
||||
HideHelpCommand: true,
|
||||
CommandNotFound: showCommandHelp,
|
||||
Name: cmdGenerate,
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate manifest file",
|
||||
ArgsUsage: "[path ...]",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
mfa.setVerbosity(cmd)
|
||||
mfa.printBanner()
|
||||
@@ -277,12 +253,10 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) checkCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdCheck,
|
||||
Usage: "Validate files using manifest file",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
ArgValidator: refuseExtraArgs,
|
||||
HideHelpCommand: true,
|
||||
CommandNotFound: showCommandHelp,
|
||||
Name: cmdCheck,
|
||||
Usage: "Validate files using manifest file",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
ArgValidator: refuseExtraArgs,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
mfa.setVerbosity(cmd)
|
||||
mfa.printBanner()
|
||||
@@ -313,12 +287,10 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) freshenCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdFreshen,
|
||||
Usage: "Update manifest with changed, new, and removed files",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
ArgValidator: refuseExtraArgs,
|
||||
HideHelpCommand: true,
|
||||
CommandNotFound: showCommandHelp,
|
||||
Name: cmdFreshen,
|
||||
Usage: "Update manifest with changed, new, and removed files",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
ArgValidator: refuseExtraArgs,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
mfa.setVerbosity(cmd)
|
||||
mfa.printBanner()
|
||||
@@ -366,12 +338,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) exportCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdExport,
|
||||
Usage: "Export manifest contents as JSON",
|
||||
ArgsUsage: "[manifest file or URL]",
|
||||
ArgValidator: refuseExtraArgs,
|
||||
HideHelpCommand: true,
|
||||
CommandNotFound: showCommandHelp,
|
||||
Name: cmdExport,
|
||||
Usage: "Export manifest contents as JSON",
|
||||
ArgsUsage: "[manifest file or URL]",
|
||||
ArgValidator: refuseExtraArgs,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
mfa.setVerbosity(cmd)
|
||||
|
||||
@@ -394,14 +364,12 @@ func (mfa *CLIApp) versionCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) listCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdList,
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List files in manifest",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
ArgValidator: refuseExtraArgs,
|
||||
HideHelpCommand: true,
|
||||
CommandNotFound: showCommandHelp,
|
||||
Action: mfa.listManifestOperation,
|
||||
Name: cmdList,
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List files in manifest",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
ArgValidator: refuseExtraArgs,
|
||||
Action: mfa.listManifestOperation,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "long",
|
||||
@@ -418,12 +386,10 @@ func (mfa *CLIApp) listCommand() *cli.Command {
|
||||
|
||||
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdFetch,
|
||||
Usage: "fetch manifest and referenced files",
|
||||
ArgsUsage: "URL",
|
||||
ArgValidator: refuseExtraArgs,
|
||||
HideHelpCommand: true,
|
||||
CommandNotFound: showCommandHelp,
|
||||
Name: cmdFetch,
|
||||
Usage: "fetch manifest and referenced files",
|
||||
ArgsUsage: "URL",
|
||||
ArgValidator: refuseExtraArgs,
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
mfa.setVerbosity(cmd)
|
||||
mfa.printBanner()
|
||||
|
||||
@@ -4,12 +4,8 @@ package cli
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -77,103 +73,6 @@ func TestGenAndFreshenSignWithKeyFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -308,105 +207,3 @@ func TestGenAsksForPassphraseOnTerminal(t *testing.T) {
|
||||
}, 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)
|
||||
}
|
||||
|
||||
+17
-32
@@ -34,10 +34,10 @@ var (
|
||||
errPathAbsolute = errors.New("is absolute; must be relative")
|
||||
errPathEmptySegment = errors.New("contains empty segment")
|
||||
errPathDotDot = errors.New("contains '..' segment")
|
||||
errPathDot = errors.New("contains '.' segment")
|
||||
errSizeMismatch = errors.New("size mismatch")
|
||||
errNegativeSize = errors.New("size cannot be negative")
|
||||
errHashNotSHA256 = errors.New("hash is not a SHA-256 multihash")
|
||||
errHashNotMultihash = errors.New("hash is not a valid multihash")
|
||||
errHashTooShort = errors.New("hash digest is too short")
|
||||
errDuplicatePath = errors.New("duplicate path")
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@ var (
|
||||
// - Must use forward slashes only (no backslashes)
|
||||
// - Must be relative (no leading /)
|
||||
// - Must not contain ".." segments
|
||||
// - Must not contain "." segments
|
||||
// - Must not contain empty segments (no "//")
|
||||
// - Must not be empty
|
||||
func ValidatePath(p string) error {
|
||||
@@ -74,10 +73,6 @@ func ValidatePath(p string) error {
|
||||
if seg == ".." {
|
||||
return fmt.Errorf("path %q %w", p, errPathDotDot)
|
||||
}
|
||||
|
||||
if seg == "." {
|
||||
return fmt.Errorf("path %q %w", p, errPathDot)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -112,28 +107,6 @@ func (m ModTime) Timestamp() *Timestamp {
|
||||
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
||||
type Multihash []byte
|
||||
|
||||
// SHA256Multihash returns the first of entry's hashes that is a SHA-256
|
||||
// multihash, or nil if none is. Loading a manifest refuses an entry that
|
||||
// does not list exactly one, so for a loaded entry it is the only one.
|
||||
func SHA256Multihash(entry *MFFilePath) Multihash {
|
||||
for _, h := range entry.GetHashes() {
|
||||
if isSHA256Multihash(h.GetMultiHash()) {
|
||||
return h.GetMultiHash()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSHA256Multihash reports whether hash is a SHA-256 multihash: the
|
||||
// multihash code for SHA-256, then a 32-byte digest.
|
||||
func isSHA256Multihash(hash []byte) bool {
|
||||
decoded, err := multihash.Decode(hash)
|
||||
|
||||
return err == nil && decoded.Code == multihash.SHA2_256 &&
|
||||
len(decoded.Digest) == sha256.Size
|
||||
}
|
||||
|
||||
// FileHashProgress reports progress during file hashing.
|
||||
type FileHashProgress struct {
|
||||
BytesRead FileSize // Total bytes read so far for the current file
|
||||
@@ -262,7 +235,8 @@ func (b *Builder) FileCount() int {
|
||||
// This is useful when the hash is already known (e.g., from an existing manifest).
|
||||
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
||||
// Returns an error if path is invalid or already added, size is negative,
|
||||
// or hash is not a SHA-256 multihash, the hash every entry must carry.
|
||||
// or hash is not a multihash with a digest of at least 32 bytes, as long
|
||||
// as SHA-256's.
|
||||
func (b *Builder) AddFileWithHash(
|
||||
path RelFilePath,
|
||||
size FileSize,
|
||||
@@ -279,8 +253,19 @@ func (b *Builder) AddFileWithHash(
|
||||
return errNegativeSize
|
||||
}
|
||||
|
||||
if !isSHA256Multihash(hash) {
|
||||
return errHashNotSHA256
|
||||
decoded, err := multihash.Decode(hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errHashNotMultihash, err)
|
||||
}
|
||||
|
||||
// The reader's limit on decoding cost (maxDecodedGrowth) assumes every
|
||||
// hash is at least as long as a SHA-256 multihash, so a manifest of
|
||||
// shorter ones could fail to load.
|
||||
if len(decoded.Digest) < sha256.Size {
|
||||
return fmt.Errorf(
|
||||
"%w: %d bytes, at least %d needed",
|
||||
errHashTooShort, len(decoded.Digest), sha256.Size,
|
||||
)
|
||||
}
|
||||
|
||||
entry := &MFFilePath{
|
||||
|
||||
+10
-16
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -95,25 +94,23 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
||||
require.NoError(t, err)
|
||||
|
||||
sha512Hash, err := multihash.Encode(make([]byte, sha512.Size), multihash.SHA2_512)
|
||||
require.NoError(t, err)
|
||||
|
||||
truncatedHash, err := multihash.Encode(make([]byte, sha256.Size-1), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash Multihash
|
||||
want error
|
||||
}{
|
||||
{"nil hash", nil},
|
||||
{"empty hash", []byte{}},
|
||||
{"one-byte hash", []byte{0x12}},
|
||||
{"nil hash", nil, errHashNotMultihash},
|
||||
{"empty hash", []byte{}, errHashNotMultihash},
|
||||
{"one-byte hash", []byte{0x12}, errHashNotMultihash},
|
||||
// A SHA-256 code and 32-byte length, then only two bytes of digest.
|
||||
{"malformed multihash", []byte{0x12, 0x20, 0x01, 0x02}},
|
||||
{"SHA-1 multihash", sha1Hash},
|
||||
{"SHA-512 multihash", sha512Hash},
|
||||
// A SHA-256 code with a 31-byte digest.
|
||||
{"31-byte digest", truncatedHash},
|
||||
{"malformed multihash", []byte{0x12, 0x20, 0x01, 0x02}, errHashNotMultihash},
|
||||
// A valid multihash, but its 20-byte SHA-1 digest is too short.
|
||||
{"SHA-1 multihash", sha1Hash, errHashTooShort},
|
||||
// A valid multihash whose 31-byte digest is one byte short.
|
||||
{"31-byte digest", truncatedHash, errHashTooShort},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -122,7 +119,7 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
||||
|
||||
b := NewBuilder()
|
||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, tt.hash)
|
||||
require.ErrorIs(t, err, errHashNotSHA256)
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
assert.Equal(t, 0, b.FileCount())
|
||||
})
|
||||
}
|
||||
@@ -289,9 +286,6 @@ func TestValidatePath(t *testing.T) {
|
||||
{"has/../traversal", "dot-dot segment"},
|
||||
{"has//double", "empty segment"},
|
||||
{"..", "just dot-dot"},
|
||||
{"./a.txt", "leading dot segment"},
|
||||
{"has/./dot", "interior dot segment"},
|
||||
{".", "just dot"},
|
||||
{string([]byte{0xff, 0xfe}), "invalid UTF-8"},
|
||||
}
|
||||
for _, tt := range invalid {
|
||||
|
||||
+25
-48
@@ -80,8 +80,7 @@ type Checker struct {
|
||||
// manifestPaths is a set of paths in the manifest for quick lookup
|
||||
manifestPaths map[RelFilePath]struct{}
|
||||
// manifestInfo is the manifest file, which FindExtraFiles leaves out,
|
||||
// matched with os.SameFile. It is nil for a manifest read from
|
||||
// CheckerOptions.ManifestReader.
|
||||
// matched with os.SameFile.
|
||||
manifestInfo os.FileInfo
|
||||
// signature info from the manifest
|
||||
signature []byte
|
||||
@@ -91,13 +90,8 @@ type Checker struct {
|
||||
|
||||
// CheckerOptions configures a Checker.
|
||||
type CheckerOptions struct {
|
||||
// ManifestPath is the manifest file to check against. It is required
|
||||
// unless ManifestReader is given.
|
||||
// ManifestPath is the manifest file to check against (required).
|
||||
ManifestPath string
|
||||
// ManifestReader, if not nil, is read for the manifest to check against
|
||||
// in place of ManifestPath, such as a manifest downloaded into memory.
|
||||
// FindExtraFiles then has no manifest file to leave out.
|
||||
ManifestReader io.Reader
|
||||
// BasePath is the directory relative to which manifest paths are
|
||||
// resolved (required).
|
||||
BasePath string
|
||||
@@ -106,10 +100,9 @@ type CheckerOptions struct {
|
||||
}
|
||||
|
||||
// NewChecker creates a new Checker with the given options. It returns an
|
||||
// error if opts is nil, the base path is empty, or neither a manifest path
|
||||
// nor a manifest reader is given.
|
||||
// error if opts is nil or either path is empty.
|
||||
func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
||||
if opts == nil || (opts.ManifestPath == "" && opts.ManifestReader == nil) {
|
||||
if opts == nil || opts.ManifestPath == "" {
|
||||
return nil, errManifestPathEmpty
|
||||
}
|
||||
|
||||
@@ -122,30 +115,12 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
||||
fs = afero.NewOsFs()
|
||||
}
|
||||
|
||||
var (
|
||||
m *manifest
|
||||
manifestInfo os.FileInfo
|
||||
err error
|
||||
)
|
||||
|
||||
if opts.ManifestReader != nil {
|
||||
m, err = NewManifestFromReader(opts.ManifestReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
m, err = NewManifestFromFile(&ManifestFromFileOptions{
|
||||
Path: opts.ManifestPath,
|
||||
Fs: fs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manifestInfo, err = fs.Stat(opts.ManifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{
|
||||
Path: opts.ManifestPath,
|
||||
Fs: fs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(opts.BasePath)
|
||||
@@ -160,6 +135,11 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
||||
manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
|
||||
}
|
||||
|
||||
manifestInfo, err := fs.Stat(opts.ManifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Checker{
|
||||
basePath: AbsFilePath(abs),
|
||||
files: files,
|
||||
@@ -284,12 +264,11 @@ func (c *Checker) Check(
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindExtraFiles walks the filesystem and reports the regular files, and
|
||||
// symlinks to them, that are not in the manifest, hidden files and
|
||||
// directories included. The manifest file itself is not reported. Anything
|
||||
// the search cannot read, such as a directory that cannot be listed, is
|
||||
// reported with StatusError and the search goes on. Results are sent to the
|
||||
// results channel. The channel is closed when done.
|
||||
// FindExtraFiles walks the filesystem and reports files not in the manifest,
|
||||
// hidden files and directories included. The manifest file itself is not
|
||||
// reported. Anything the search cannot read, such as a directory that cannot
|
||||
// be listed, is reported with StatusError and the search goes on. Results are
|
||||
// sent to the results channel. The channel is closed when done.
|
||||
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
||||
if results != nil {
|
||||
defer close(results)
|
||||
@@ -339,6 +318,11 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// gen never lists a named pipe, a socket or a device.
|
||||
if info.Mode()&(os.ModeNamedPipe|os.ModeSocket|os.ModeDevice) != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A symlink is compared by what it points to, so a manifest reached
|
||||
// through one is not reported either.
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
@@ -348,13 +332,6 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
||||
}
|
||||
}
|
||||
|
||||
// gen lists regular files and symlinks to them only, so a named
|
||||
// pipe, a socket, a device, a symlink to a directory and a broken
|
||||
// symlink are never reported.
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip the manifest file itself, however its path is spelled
|
||||
if os.SameFile(info, c.manifestInfo) {
|
||||
return nil
|
||||
|
||||
@@ -189,38 +189,6 @@ func TestNewCheckerNilFsUsesOsFs(t *testing.T) {
|
||||
assert.Equal(t, StatusOK, (<-results).Status)
|
||||
}
|
||||
|
||||
// TestNewCheckerReadsManifestFromReader gives NewChecker the manifest as a
|
||||
// reader and no manifest path, on a filesystem that holds only the files it
|
||||
// lists: it checks those files and finds nothing unlisted.
|
||||
func TestNewCheckerReadsManifestFromReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := map[string][]byte{testFile1: []byte("hello")}
|
||||
|
||||
manifestFs := afero.NewMemMapFs()
|
||||
createTestManifest(t, manifestFs, testManifestPath, files)
|
||||
manifestData, err := afero.ReadFile(manifestFs, testManifestPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
createFilesOnDisk(t, fs, files)
|
||||
|
||||
chk, err := NewChecker(&CheckerOptions{
|
||||
ManifestReader: bytes.NewReader(manifestData),
|
||||
BasePath: testDataDir,
|
||||
Fs: fs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 1)
|
||||
require.NoError(t, chk.Check(context.Background(), results, nil))
|
||||
assert.Equal(t, Result{Path: testFile1, Status: StatusOK}, <-results)
|
||||
|
||||
extras := make(chan Result, len(files))
|
||||
require.NoError(t, chk.FindExtraFiles(context.Background(), extras))
|
||||
assert.Empty(t, extras)
|
||||
}
|
||||
|
||||
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package mfer
|
||||
|
||||
const (
|
||||
// Version is the current mfer release version.
|
||||
Version = "0.1.0"
|
||||
|
||||
// ReleaseDate is the date on which Version was released.
|
||||
ReleaseDate = "2025-12-17"
|
||||
|
||||
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
||||
// data (256 MiB). This prevents decompression bombs from consuming excessive
|
||||
// memory.
|
||||
|
||||
+5
-39
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
@@ -28,9 +27,7 @@ var (
|
||||
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
||||
errInvalidFileFormat = errors.New("invalid file format")
|
||||
errInvalidManifestPath = errors.New("invalid file entry")
|
||||
errEntryMissingHash = errors.New("file entry has no SHA-256 hash")
|
||||
errEntryExtraSHA256Hash = errors.New("file entry has more than one SHA-256 hash")
|
||||
errModeOutOfRange = errors.New("file entry has a mode above 0777")
|
||||
errEntryMissingHash = errors.New("file entry has no hash")
|
||||
errDecodedTooLarge = errors.New("too much memory needed")
|
||||
errSignerNotSigningKey = errors.New(
|
||||
"signer is not the fingerprint of the key that made the signature")
|
||||
@@ -239,30 +236,6 @@ func forEachBytesField(
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEntryHashes refuses, naming its path, an entry that does not list
|
||||
// exactly one SHA-256 multihash; hashes of other algorithms beside it are
|
||||
// allowed. check and fetch compute only SHA-256, so with none they would
|
||||
// have nothing to check the file against, and with two check would pass a
|
||||
// file that matches either.
|
||||
func validateEntryHashes(entry *MFFilePath) error {
|
||||
sha256Hashes := 0
|
||||
|
||||
for _, h := range entry.GetHashes() {
|
||||
if isSHA256Multihash(h.GetMultiHash()) {
|
||||
sha256Hashes++
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case sha256Hashes == 0:
|
||||
return fmt.Errorf("%w: %q", errEntryMissingHash, entry.GetPath())
|
||||
case sha256Hashes > 1:
|
||||
return fmt.Errorf("%w: %q", errEntryExtraSHA256Hash, entry.GetPath())
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manifest) deserializeInner() error {
|
||||
err := m.validateOuterHeader()
|
||||
if err != nil {
|
||||
@@ -321,10 +294,8 @@ func (m *manifest) deserializeInner() error {
|
||||
// untrusted .mf. Reject loudly on the first offender rather than
|
||||
// dropping entries, which would let a hostile manifest hide files from a
|
||||
// check. A path listed twice is refused too: check would check the one
|
||||
// file against both entries. So is an entry that does not list exactly
|
||||
// one SHA-256 hash, and one that records a mode above 0777, such as one
|
||||
// with setuid: a mode holds the permission bits alone, which are all
|
||||
// check compares and fetch sets.
|
||||
// file against both entries. So is an entry with no hash: there is
|
||||
// nothing to check the file against.
|
||||
seen := make(map[string]bool, len(m.pbInner.GetFiles()))
|
||||
|
||||
for _, f := range m.pbInner.GetFiles() {
|
||||
@@ -333,13 +304,8 @@ func (m *manifest) deserializeInner() error {
|
||||
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
|
||||
}
|
||||
|
||||
err = validateEntryHashes(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.GetMode() > uint32(os.ModePerm) {
|
||||
return fmt.Errorf("%w: %q (%#o)", errModeOutOfRange, f.GetPath(), f.GetMode())
|
||||
if len(f.GetHashes()) == 0 {
|
||||
return fmt.Errorf("%w: %q", errEntryMissingHash, f.GetPath())
|
||||
}
|
||||
|
||||
if seen[f.GetPath()] {
|
||||
|
||||
+25
-153
@@ -5,11 +5,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"uuid"
|
||||
@@ -83,8 +80,6 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
||||
}{
|
||||
{"parent traversal", "../escape"},
|
||||
{"interior traversal", "a/../../escape"},
|
||||
{"leading dot segment", "./a.txt"},
|
||||
{"interior dot segment", "dir/./a.txt"},
|
||||
{"absolute path", "/etc/passwd"},
|
||||
{"backslash path", `a\b`},
|
||||
{"double slash", "a//b"},
|
||||
@@ -170,154 +165,40 @@ func TestDeserializeRefusesPathListedTwice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A file entry must list exactly one SHA-256 hash, and may list hashes of
|
||||
// other algorithms beside it. An entry with none, even one that lists a
|
||||
// SHA-512 hash, is refused as it is loaded, and so is an entry with two;
|
||||
// each refusal names the path. An entry listing a SHA-512 hash and then a
|
||||
// SHA-256 one loads with both.
|
||||
func TestDeserializeRequiresExactlyOneSHA256Hash(t *testing.T) {
|
||||
// A manifest holding a file entry with no hash is refused as it is loaded,
|
||||
// naming the path: there is nothing to check the file against.
|
||||
func TestDeserializeRefusesEntryWithNoHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sha256Hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
id := uuid.NewV4()
|
||||
inner, err := proto.Marshal(&MFFile{
|
||||
Version: MFFile_VERSION_ONE,
|
||||
Files: []*MFFilePath{{Path: "x", Size: 1}},
|
||||
Uuid: id[:],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherSHA256Hash, err := multihash.Encode(
|
||||
bytes.Repeat([]byte{1}, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
sha512Hash, err := multihash.Encode(make([]byte, sha512.Size), multihash.SHA2_512)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hashes [][]byte
|
||||
want error // nil for an entry that loads
|
||||
}{
|
||||
{"no hash", nil, errEntryMissingHash},
|
||||
{"SHA-512 hash only", [][]byte{sha512Hash}, errEntryMissingHash},
|
||||
{
|
||||
"two SHA-256 hashes",
|
||||
[][]byte{sha256Hash, otherSHA256Hash},
|
||||
errEntryExtraSHA256Hash,
|
||||
},
|
||||
{"SHA-512 hash then SHA-256 hash", [][]byte{sha512Hash, sha256Hash}, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := &MFFilePath{Path: "x", Size: 1}
|
||||
for _, h := range tt.hashes {
|
||||
entry.Hashes = append(entry.Hashes, &MFFileChecksum{MultiHash: h})
|
||||
}
|
||||
|
||||
id := uuid.NewV4()
|
||||
inner, err := proto.Marshal(&MFFile{
|
||||
Version: MFFile_VERSION_ONE,
|
||||
Files: []*MFFilePath{entry},
|
||||
Uuid: id[:],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
||||
if tt.want == nil {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, m.Files(), 1)
|
||||
assert.Len(t, m.Files()[0].GetHashes(), len(tt.hashes))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
assert.Contains(t, err.Error(), `"x"`)
|
||||
})
|
||||
}
|
||||
_, err = NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
||||
require.ErrorIs(t, err, errEntryMissingHash)
|
||||
require.EqualError(t, err, `file entry has no hash: "x"`)
|
||||
}
|
||||
|
||||
// A file entry that records a mode with more than the permission bits is
|
||||
// refused as it is loaded, naming the path and the mode: once with setuid as
|
||||
// Unix writes it (04755) and once as Go keeps it in os.FileMode. An entry
|
||||
// recording 0777 loads.
|
||||
func TestDeserializeRefusesModeOutsidePermissionBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mode uint32
|
||||
want string // "" for an entry that loads
|
||||
}{
|
||||
{
|
||||
"setuid as Unix writes it",
|
||||
0o4755,
|
||||
`file entry has a mode above 0777: "x" (04755)`,
|
||||
},
|
||||
{
|
||||
"setuid as Go keeps it",
|
||||
uint32(os.ModeSetuid | 0o755),
|
||||
`file entry has a mode above 0777: "x" (040000755)`,
|
||||
},
|
||||
{"permission bits only", 0o777, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id := uuid.NewV4()
|
||||
inner, err := proto.Marshal(&MFFile{
|
||||
Version: MFFile_VERSION_ONE,
|
||||
Files: []*MFFilePath{{
|
||||
Path: "x",
|
||||
Size: 1,
|
||||
Hashes: []*MFFileChecksum{{MultiHash: hash}},
|
||||
Mode: tt.mode,
|
||||
}},
|
||||
Uuid: id[:],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
||||
if tt.want == "" {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, m.Files(), 1)
|
||||
assert.Equal(t, tt.mode, m.Files()[0].GetMode())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, errModeOutOfRange)
|
||||
require.EqualError(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Entries of a path, a SHA-256 hash, an empty MIME type and empty
|
||||
// modification and change times are counted at 432 bytes each (176 + 112 +
|
||||
// 16 + 64 + 64) and take 52 bytes plus the path to encode. A one-character
|
||||
// path makes that 53 bytes, about 8.2 times: refused, and leaving any one of
|
||||
// the five uncounted, even the MIME type, brings it under 8. A two-character
|
||||
// path makes it 54 bytes, 8 times exactly: loaded. There is one entry for
|
||||
// each base-36 digit, and each entry's path is its number in base 36, padded
|
||||
// with zeros to that length, since a manifest lists a path only once.
|
||||
// Entries of a path, an empty hash, an empty MIME type and empty modification
|
||||
// and change times are counted at 432 bytes each (176 + 112 + 16 + 64 + 64)
|
||||
// and take 16 bytes plus the path to encode. A 37-character path makes that
|
||||
// 53 bytes, about 8.2 times: refused, and leaving any one of the five
|
||||
// uncounted, even the MIME type, brings it under 8. A 39-character path makes
|
||||
// it 55 bytes, about 7.9 times: loaded. Each entry's path is its number,
|
||||
// padded with zeros to that length, since a manifest lists a path only once.
|
||||
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
checksum, err := proto.Marshal(&MFFileChecksum{MultiHash: hash})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
pathLen int
|
||||
refused bool
|
||||
}{
|
||||
{1, true},
|
||||
{2, false},
|
||||
{37, true},
|
||||
{39, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -330,14 +211,11 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
|
||||
inner = protowire.AppendBytes(inner, id[:])
|
||||
|
||||
for i := range 36 {
|
||||
path := strconv.FormatInt(int64(i), 36)
|
||||
path = strings.Repeat("0", tt.pathLen-len(path)) + path
|
||||
|
||||
for i := range 1000 {
|
||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
||||
entry = protowire.AppendString(entry, path)
|
||||
entry = protowire.AppendString(entry, fmt.Sprintf("%0*d", tt.pathLen, i))
|
||||
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||
entry = protowire.AppendBytes(entry, checksum)
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
|
||||
@@ -367,16 +245,10 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
|
||||
unknown := protowire.AppendTag(nil, 99, protowire.BytesType) // in no message
|
||||
unknown = protowire.AppendBytes(unknown, []byte("not known"))
|
||||
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
checksum, err := proto.Marshal(&MFFileChecksum{MultiHash: hash})
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
||||
entry = protowire.AppendString(entry, "a")
|
||||
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||
entry = protowire.AppendBytes(entry, checksum)
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = append(entry, unknown...)
|
||||
|
||||
id := uuid.NewV4()
|
||||
|
||||
+10
-22
@@ -3,9 +3,6 @@ package mfer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"testing"
|
||||
"uuid"
|
||||
|
||||
@@ -56,35 +53,26 @@ func TestReadAtMost(t *testing.T) {
|
||||
assert.Equal(t, maxSize-1, input.Len(), "bytes left unread")
|
||||
}
|
||||
|
||||
// TestFormatTestVector checks the test vector that docs/FORMAT.md shows: the
|
||||
// SHA-256 of the whole file, so that any change to the file fails, then loads
|
||||
// it, verifies its signature, and checks the canonical string and the signer
|
||||
// the document gives for it.
|
||||
// TestFormatTestVector loads the test vector that docs/FORMAT.md shows,
|
||||
// verifies its signature, and checks the signed string and the signer the
|
||||
// document gives for it.
|
||||
func TestFormatTestVector(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
path = "testdata/test-vector.mf"
|
||||
fileSHA256 = "5864187d671b2a55f8e015f858248f92c51956b549cf548b6bb54477c2407fd4"
|
||||
signer = "5E962A37590D855B467D20294947A60319611E6F"
|
||||
)
|
||||
const signer = "5E962A37590D855B467D20294947A60319611E6F"
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{
|
||||
Path: "testdata/test-vector.mf",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
require.Equal(t, fileSHA256, hex.EncodeToString(sum[:]))
|
||||
|
||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{Path: path})
|
||||
require.NoError(t, err)
|
||||
|
||||
canonicalString, err := m.signatureString()
|
||||
signedString, err := m.signatureString()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ZNAVSRFG-92197fd532bc40908aa9f86e5518b56c-"+
|
||||
"12202353553205f036a88cefee3d0d9049ccd8e68bb24011a0b462b5867f1fcc6352",
|
||||
canonicalString)
|
||||
signedString)
|
||||
|
||||
keyFingerprint, err := verifySignature([]byte(canonicalString),
|
||||
keyFingerprint, err := verifySignature([]byte(signedString),
|
||||
m.pbOuter.GetSignature(), m.pbOuter.GetSigningPubKey())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, signer, keyFingerprint)
|
||||
|
||||
@@ -62,12 +62,6 @@ func TestValidatePathMessagesVerbatim(t *testing.T) {
|
||||
want: `path "a/../b" contains '..' segment`,
|
||||
is: errPathDotDot,
|
||||
},
|
||||
{
|
||||
name: "dot segment",
|
||||
path: "a/./b",
|
||||
want: `path "a/./b" contains '.' segment`,
|
||||
is: errPathDot,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -47,23 +47,6 @@ func (m *manifest) Files() []*MFFilePath {
|
||||
return m.pbInner.GetFiles()
|
||||
}
|
||||
|
||||
// Signer returns the fingerprint of the key that made a loaded manifest's
|
||||
// signature, which loading the manifest checked, or nil if the manifest is
|
||||
// not signed.
|
||||
func (m *manifest) Signer() []byte {
|
||||
if len(m.pbOuter.GetSignature()) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.pbOuter.GetSigner()
|
||||
}
|
||||
|
||||
// CreatedAt returns the createdAt timestamp of a loaded manifest, or nil
|
||||
// if the manifest has none.
|
||||
func (m *manifest) CreatedAt() *Timestamp {
|
||||
return m.pbInner.GetCreatedAt()
|
||||
}
|
||||
|
||||
// signatureString generates the canonical string used for signing/verification.
|
||||
// Format: MAGIC-UUID-MULTIHASH where UUID and multihash are hex-encoded.
|
||||
// Requires pbOuter to be set with Uuid and Sha256 fields.
|
||||
|
||||
+2
-2
@@ -333,8 +333,8 @@ type MFFilePath struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// required attributes:
|
||||
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||
// be relative (no leading /), contain no ".." or "." segments, and
|
||||
// no empty segments (no "//").
|
||||
// be relative (no leading /), contain no ".." segments, and no
|
||||
// empty segments (no "//").
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
|
||||
// gotta have at least one:
|
||||
|
||||
+2
-2
@@ -49,8 +49,8 @@ message MFFileOuter {
|
||||
message MFFilePath {
|
||||
// required attributes:
|
||||
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||
// be relative (no leading /), contain no ".." or "." segments, and
|
||||
// no empty segments (no "//").
|
||||
// be relative (no leading /), contain no ".." segments, and no
|
||||
// empty segments (no "//").
|
||||
string path = 1;
|
||||
int64 size = 2;
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1668b2f6b8639a9213873474e58dbacb3a4ef77cc27c42253cad8dd076e5f206 mf.proto
|
||||
4f8ea5276a8e66590e71ed154a9176b0f68ffa9ab69d8de5aff0e0d9b731164d mf.proto
|
||||
|
||||
+3
-39
@@ -57,9 +57,7 @@ var (
|
||||
"signature must hold exactly one signature")
|
||||
errNotOneArmoredBlock = errors.New(
|
||||
"must be exactly one armored block and nothing else")
|
||||
errMalformedArmor = errors.New("armor is malformed")
|
||||
errKeyNotValidWhenSigned = errors.New(
|
||||
"signature was made when its key was not valid")
|
||||
errMalformedArmor = errors.New("armor is malformed")
|
||||
)
|
||||
|
||||
// SigningOptions holds the key a manifest is signed with.
|
||||
@@ -83,19 +81,6 @@ func SecretKeyIsProtected(secretKey []byte) (bool, error) {
|
||||
return isProtected(key), nil
|
||||
}
|
||||
|
||||
// SecretKeyFingerprint returns the fingerprint of the OpenPGP secret key
|
||||
// secretKey, armored or binary, as a manifest signed with it records its
|
||||
// signer. It fails unless secretKey holds one version 4 primary key with
|
||||
// its secret key.
|
||||
func SecretKeyFingerprint(secretKey []byte) (string, error) {
|
||||
key, err := readSecretKey(secretKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fingerprint(key), nil
|
||||
}
|
||||
|
||||
// CheckSigningKey fails unless opts can sign now: opts.SecretKey must hold
|
||||
// one version 4 primary key with a secret key that may sign and has not
|
||||
// expired or been revoked, and opts.Passphrase must unlock it when it is
|
||||
@@ -260,9 +245,8 @@ func fingerprint(key *openpgp.Entity) string {
|
||||
|
||||
// verifySignature checks that signature is one good, unexpired OpenPGP
|
||||
// signature over data, made by the one primary key in pubKey or one of its
|
||||
// subkeys while that key was valid, and returns that primary key's
|
||||
// fingerprint. The key may have expired since. signature and pubKey may
|
||||
// each be armored or binary.
|
||||
// subkeys, and returns that primary key's fingerprint. signature and pubKey
|
||||
// may each be armored or binary.
|
||||
func verifySignature(data, signature, pubKey []byte) (string, error) {
|
||||
key, err := readOneKey(pubKey, "embedded public key block", true)
|
||||
if err != nil {
|
||||
@@ -297,26 +281,6 @@ func verifySignature(data, signature, pubKey []byte) (string, error) {
|
||||
return "", fmt.Errorf("verify signature: %w", err)
|
||||
}
|
||||
|
||||
// The check above judges the key as of now. Checking the signature a
|
||||
// second time with the library's clock set to its creation time
|
||||
// requires the primary key, and the subkey that made the signature, to
|
||||
// have been valid then under the key validity rules of RFC 9580. The
|
||||
// key is judged by its current self-signatures and binding signatures:
|
||||
// for a version 4 key the newest self-signature on its primary user
|
||||
// ID, for a version 6 key its newest direct-key signature, and for a
|
||||
// subkey its newest binding signature with the signature embedded in
|
||||
// it. So a signature made before the newest of those is refused.
|
||||
signedAt := &packet.Config{
|
||||
Time: func() time.Time { return sig.CreationTime },
|
||||
}
|
||||
|
||||
_, _, err = openpgp.VerifyDetachedSignature(openpgp.EntityList{key},
|
||||
bytes.NewReader(data), bytes.NewReader(sigData), signedAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("verify signature: %w: %w",
|
||||
errKeyNotValidWhenSigned, err)
|
||||
}
|
||||
|
||||
return fingerprint(key), nil
|
||||
}
|
||||
|
||||
|
||||
+2
-160
@@ -4,7 +4,6 @@ package mfer
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/dsa" //nolint:staticcheck // SA1019: tests need a DSA key to refuse
|
||||
"io"
|
||||
"math/big"
|
||||
@@ -177,25 +176,6 @@ func TestBuilderWithSigning(t *testing.T) {
|
||||
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||
}
|
||||
|
||||
// TestManifestSigner checks that Signer gives the signing key's
|
||||
// fingerprint for a signed manifest, and nil once the signature is taken
|
||||
// out, although the signer field is still there.
|
||||
func TestManifestSigner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := newTestKey(t, nil)
|
||||
data := signedTestManifest(t, &SigningOptions{SecretKey: armoredSecretKeys(t, key)})
|
||||
|
||||
signed, err := NewManifestFromReader(bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fingerprint(key), string(signed.Signer()))
|
||||
|
||||
unsigned, err := NewManifestFromReader(bytes.NewReader(
|
||||
rewriteOuter(t, data, func(outer *MFFileOuter) { outer.Signature = nil })))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, unsigned.Signer())
|
||||
}
|
||||
|
||||
func TestScannerWithSigning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -281,21 +261,6 @@ func TestSecretKeyIsProtectedWithoutPassphrase(t *testing.T) {
|
||||
assert.False(t, protected)
|
||||
}
|
||||
|
||||
// TestSecretKeyFingerprint checks that SecretKeyFingerprint gives the
|
||||
// signer that a manifest signed with the key records.
|
||||
func TestSecretKeyFingerprint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := testSigningOptions(t)
|
||||
|
||||
got, err := SecretKeyFingerprint(opts.SecretKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(signedTestManifest(t, opts)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(m.Signer()), got)
|
||||
}
|
||||
|
||||
// TestSigningKeyFileWithTwoKeys signs with a key file that holds two keys,
|
||||
// as gpg writes it for a user ID that two keys have. It names no one key
|
||||
// to sign with, so signing must fail.
|
||||
@@ -454,9 +419,8 @@ func TestVerifySignature(t *testing.T) {
|
||||
|
||||
// TestVerifySignatureKeyExpiredSince verifies two signatures made in 2020
|
||||
// by a key that expired a day after it was made. A signature with no
|
||||
// expiry time is still good, as is one made then by a signing subkey that
|
||||
// expired a day after it was made. One that expired an hour after it was
|
||||
// made is refused, although the key has expired too.
|
||||
// expiry time is still good. One that expired an hour after it was made is
|
||||
// refused, although the key has expired too.
|
||||
func TestVerifySignatureKeyExpiredSince(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -484,132 +448,10 @@ func TestVerifySignatureKeyExpiredSince(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fingerprint(key), signer)
|
||||
|
||||
withSubkey := newTestKey(t, &packet.Config{Time: config.Time})
|
||||
require.NoError(t, withSubkey.AddSigningSubkey(&packet.Config{
|
||||
Algorithm: packet.PubKeyAlgoEdDSA, Time: config.Time,
|
||||
KeyLifetimeSecs: config.KeyLifetimeSecs,
|
||||
}))
|
||||
|
||||
var subkeySig bytes.Buffer
|
||||
|
||||
require.NoError(t, openpgp.ArmoredDetachSign(&subkeySig, withSubkey,
|
||||
bytes.NewReader(data), config))
|
||||
|
||||
subkeyPubKey, err := armoredPublicKey(withSubkey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = verifySignature(data, subkeySig.Bytes(), subkeyPubKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = verifySignature(data, expiringSig.Bytes(), pubKey)
|
||||
require.ErrorIs(t, err, pgperrors.ErrSignatureExpired)
|
||||
}
|
||||
|
||||
// signDated returns a detached signature over data by signer, dated when.
|
||||
// openpgp.DetachSign signs only with a key that is valid at the time its
|
||||
// config gives, so this makes the signature packet without that check.
|
||||
func signDated(t *testing.T, signer *packet.PrivateKey, data []byte,
|
||||
when time.Time,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
|
||||
sig := &packet.Signature{
|
||||
SigType: packet.SigTypeBinary,
|
||||
PubKeyAlgo: signer.PubKeyAlgo,
|
||||
Hash: crypto.SHA256,
|
||||
CreationTime: when,
|
||||
IssuerKeyId: &signer.KeyId,
|
||||
}
|
||||
|
||||
h, err := sig.PrepareSign(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = h.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sig.Sign(h, signer, nil))
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
require.NoError(t, sig.Serialize(&buf))
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestVerifySignatureRefusesKeyNotValidWhenSigned verifies signatures
|
||||
// dated when the key or subkey that made them was not valid: after the key
|
||||
// expired; after the key's self-signature, or the signing subkey's binding
|
||||
// signature, expired, the key or subkey having expired since as well; and
|
||||
// before the key was made, also by a key made in 2100. Each must be
|
||||
// refused.
|
||||
func TestVerifySignatureRefusesKeyNotValidWhenSigned(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
twoDaysLater := made.Add(48 * time.Hour)
|
||||
day := uint32((24 * time.Hour).Seconds())
|
||||
clock := func(when time.Time) func() time.Time {
|
||||
return func() time.Time { return when }
|
||||
}
|
||||
|
||||
expiredAfterADay := newTestKey(t, &packet.Config{
|
||||
Time: clock(made), KeyLifetimeSecs: day,
|
||||
})
|
||||
selfSignatureExpiredAfterADay := newTestKey(t, &packet.Config{
|
||||
Time: clock(made), KeyLifetimeSecs: 30 * day, SigLifetimeSecs: day,
|
||||
})
|
||||
|
||||
withSubkey := newTestKey(t, &packet.Config{Time: clock(made)})
|
||||
require.NoError(t, withSubkey.AddSigningSubkey(&packet.Config{
|
||||
Algorithm: packet.PubKeyAlgoEdDSA, Time: clock(made),
|
||||
KeyLifetimeSecs: 30 * day, SigLifetimeSecs: day,
|
||||
}))
|
||||
subkey := withSubkey.Subkeys[len(withSubkey.Subkeys)-1].PrivateKey
|
||||
|
||||
madeADayLater := newTestKey(t, &packet.Config{
|
||||
Time: clock(made.Add(24 * time.Hour)),
|
||||
})
|
||||
madeIn2100 := newTestKey(t, &packet.Config{
|
||||
Time: clock(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||
})
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
key *openpgp.Entity
|
||||
signer *packet.PrivateKey
|
||||
when time.Time
|
||||
}{
|
||||
"after the key expired": {
|
||||
expiredAfterADay, expiredAfterADay.PrivateKey, twoDaysLater,
|
||||
},
|
||||
"after the self-signature expired": {
|
||||
selfSignatureExpiredAfterADay,
|
||||
selfSignatureExpiredAfterADay.PrivateKey, twoDaysLater,
|
||||
},
|
||||
"after the binding signature expired": {
|
||||
withSubkey, subkey, twoDaysLater,
|
||||
},
|
||||
"before the key was made": {
|
||||
madeADayLater, madeADayLater.PrivateKey, made,
|
||||
},
|
||||
"before a key made in 2100": {
|
||||
madeIn2100, madeIn2100.PrivateKey, made,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte("signed in 2020")
|
||||
|
||||
pubKey, err := armoredPublicKey(test.key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = verifySignature(data,
|
||||
signDated(t, test.signer, data, test.when), pubKey)
|
||||
require.ErrorIs(t, err, errKeyNotValidWhenSigned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestSignatureVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user