Author SHA1 Message Date
sneak 96f879912c Compare --require-signature with the key that signed (closes #167)
check / check (push) Waiting to run
check and fetch --require-signature compared the required fingerprint
with the first key in the manifest's embedded public key block, while
gpg accepted a good signature by any key in that block.

Loading a signed manifest now refuses one whose embedded block holds
more than one primary key, counted as gpg reads the block, or whose
signer field is not the primary key fingerprint gpg reports for the
signature. --require-signature compares with the signer field, which
loading has checked. Signing names and embeds the key gpg reports it
signed with, so a key ID matching several keys still writes a manifest
that loads. docs/FORMAT.md states what a verifier checks.

Model: opus-5-5
2026-10-07 11:03:31 +00:00
35 changed files with 1345 additions and 2203 deletions
+4 -34
View File
@@ -9,9 +9,8 @@ downloading, streaming, and mirroring. It was first published in 2022. The
manifest files' data is serialized with Google's manifest files' data is serialized with Google's
[protobuf serialization format](https://developers.google.com/protocol-buffers). [protobuf serialization format](https://developers.google.com/protocol-buffers).
The structure of these files can be found The structure of these files can be found
[in the format specification](docs/FORMAT.md), which refers to the protobuf [in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
schema `mfer/mf.proto` for exact field numbers and types. Both are included in which is included in the [project repository](https://git.eeqj.de/sneak/mfer).
the [project repository](https://git.eeqj.de/sneak/mfer).
The current version is pre-1.0 and while the repo was published in 2022, there 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 has not yet been any versioned release. [SemVer](https://semver.org) will be
@@ -257,9 +256,8 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
- Should the manifest signature format be GnuPG signatures, or those from - Should the manifest signature format be GnuPG signatures, or those from
OpenBSD's signify (of which there is a good OpenBSD's signify (of which there is a good
[golang implementation](https://github.com/frankbraun/gosignify))? Settled [golang implementation](https://github.com/frankbraun/gosignify))? Still open,
under question 10 on [issue 82](https://git.eeqj.de/sneak/mfer/issues/82): as question 10 on [issue 82](https://git.eeqj.de/sneak/mfer/issues/82).
OpenPGP signatures, which mfer makes and checks itself without running `gpg`.
- Should the on-disk serialization format be proto3 or json? Settled: it is - Should the on-disk serialization format be proto3 or json? Settled: it is
proto3, see `docs/FORMAT.md` and `mfer/mf.proto`. proto3, see `docs/FORMAT.md` and `mfer/mf.proto`.
@@ -270,41 +268,13 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
- recurses under current directory and writes out an `index.mf` - recurses under current directory and writes out an `index.mf`
- records every file's mode as `0000` unless given `--include-permissions`, - records every file's mode as `0000` unless given `--include-permissions`,
which records each file's permission bits (`0777` at most) which records each file's permission bits (`0777` at most)
- `mfer gen /media/drive`
- writes `/media/drive/index.mf`, listing each file by its path under
`/media/drive`, so `mfer check /media/drive` verifies it. Given a file,
gen writes `index.mf` beside it and lists the file by its name; given
several paths, it writes `index.mf` in the current directory
- `--output` names another file to write instead. What gen lists depends
only on the paths it is given and the files under them, so with the same
`--seed` and an unchanged tree it writes the same bytes wherever the
manifest goes. The file it writes to is never listed
- `mfer check` / `mfer check .` - `mfer check` / `mfer check .`
- verifies checksums of all files in manifest, displaying error and exiting - verifies checksums of all files in manifest, displaying error and exiting
nonzero if any files are missing or corrupted, or have permission bits nonzero if any files are missing or corrupted, or have permission bits
other than the mode the manifest records, unless that is `0000` other than the mode the manifest records, unless that is `0000`
- looks for those files under the base directory: the one `--base` names, or
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 file under the base directory that the manifest does not - 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 list, hidden files included; with `--no-extra-files` each one is a failure
instead 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
changed
- leaves out hidden files unless given `--include-dotfiles`, and symlinks
unless given `--follow-symlinks`, which lists each symlink to a file under
its own name with the contents of the file it points to
- `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
file too. mfer signs it itself and does not need `gpg`. A file holding
more than one key is refused, and a key held only on a smartcard cannot
sign
- takes a protected key's passphrase from `MFER_SIGN_KEY_PASSPHRASE`, or
else asks for it at the terminal
- `mfer fetch https://example.com/stuff/` - `mfer fetch https://example.com/stuff/`
- fetches `/stuff/index.mf` and downloads all files listed in manifest into - fetches `/stuff/index.mf` and downloads all files listed in manifest into
the current directory, or the one given with `--dest`, and assures the current directory, or the one given with `--dest`, and assures
+10 -16
View File
@@ -6,7 +6,7 @@ Version 1.0
An `.mf` file is a binary manifest that describes a directory tree of files, An `.mf` file is a binary manifest that describes a directory tree of files,
including their paths, sizes, and cryptographic checksums. It supports optional including their paths, sizes, and cryptographic checksums. It supports optional
OpenPGP signatures for integrity verification and optional timestamps and file GPG signatures for integrity verification and optional timestamps and file
permissions for metadata preservation. permissions for metadata preservation.
Nothing goes in the 1.0 manifest that 1.0 does not read or write: no field is Nothing goes in the 1.0 manifest that 1.0 does not read or write: no field is
@@ -36,9 +36,9 @@ The outer message contains:
| `sha256` | 104 | bytes | SHA-256 hash of the **compressed** `innerMessage` (corruption detection) | | `sha256` | 104 | bytes | SHA-256 hash of the **compressed** `innerMessage` (corruption detection) |
| `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID | | `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID |
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message | | `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
| `signature` | 201 | bytes (optional) | OpenPGP detached signature (ASCII-armored or binary) | | `signature` | 201 | bytes (optional) | GPG signature (ASCII-armored or binary) |
| `signer` | 202 | bytes (optional) | Fingerprint of the signing key | | `signer` | 202 | bytes (optional) | Fingerprint of the signing key |
| `signingPubKey` | 203 | bytes (optional) | Full OpenPGP public key of the signing key (ASCII-armored or binary) | | `signingPubKey` | 203 | bytes (optional) | Full GPG signing public key |
### SHA-256 Hash ### SHA-256 Hash
@@ -50,14 +50,11 @@ allows verifying data integrity before decompression.
The `innerMessage` field is compressed with The `innerMessage` field is compressed with
[Zstandard (zstd)](https://facebook.github.io/zstd/). Implementations must [Zstandard (zstd)](https://facebook.github.io/zstd/). Implementations must
enforce a decompression size limit to prevent decompression bombs. The reference enforce a decompression size limit to prevent decompression bombs. The reference
implementation limits decompressed size to 256 MiB. It writes zstd frames with a implementation limits decompressed size to 256 MB. It writes zstd frames with a
window of at most 8 MiB, the largest window the zstd format recommends decoders window of at most 8 MiB, the largest window the zstd format recommends decoders
support, and refuses frames that ask for a larger one. It also refuses an inner support, and refuses frames that ask for a larger one. It also refuses an inner
message whose file entries, hashes, timestamps and MIME types, counted at 176, message whose file entries, hashes, timestamps and MIME types, counted at 176,
112, 64 and 16 bytes each, add up to more than 8 times its size. It refuses a 112, 64 and 16 bytes each, add up to more than 8 times its size.
manifest file larger than 258 MiB without reading the rest of it: zstd's worst
case grows a 256 MiB inner message by 1/256 to 257 MiB, and the last MiB is room
for the signature, the signing key and the other outer fields.
## Inner Message (`MFFile`) ## Inner Message (`MFFile`)
@@ -109,12 +106,9 @@ All `path` values must satisfy these invariants:
- **No parent traversal**: no `..` path segments - **No parent traversal**: no `..` path segments
- **No empty segments**: no `//` sequences - **No empty segments**: no `//` sequences
- **No trailing slash**: paths refer to files, not directories - **No trailing slash**: paths refer to files, not directories
- **Listed once**: each path appears at most once in a manifest, compared byte
for byte, so `A.txt` and `a.txt` are two paths
Implementations must validate these invariants when reading and writing Implementations must validate these invariants when reading and writing
manifests. Paths that violate these rules must be rejected, and a reader must manifests. Paths that violate these rules must be rejected.
reject a manifest that lists a path more than once.
## Hash Format (`MFFileChecksum`) ## Hash Format (`MFFileChecksum`)
@@ -142,10 +136,10 @@ Where:
- `<SHA256>` is the hex-encoded SHA-256 hash from the outer message (covering - `<SHA256>` is the hex-encoded SHA-256 hash from the outer message (covering
compressed data) compressed data)
Components are separated by hyphens. The signature is an OpenPGP detached Components are separated by hyphens. The signature is produced by GPG over this
signature over this canonical string, stored in the `signature` field of the canonical string and stored in the `signature` field of the outer message. The
outer message. The signing key's public key goes in `signingPubKey` and its signing key's public key goes in `signingPubKey` and its fingerprint, in hex, in
fingerprint, in hex, in `signer`. `signer`.
A verifier accepts a signed manifest only if `signingPubKey` holds exactly one A verifier accepts a signed manifest only if `signingPubKey` holds exactly one
primary key, `signature` is one good signature over the canonical string made by primary key, `signature` is one good signature over the canonical string made by
-2
View File
@@ -3,7 +3,6 @@ module sneak.berlin/go/mfer
go 1.27.1 go 1.27.1
require ( require (
github.com/ProtonMail/go-crypto v1.5.2
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/dustin/go-humanize v1.1.0 github.com/dustin/go-humanize v1.1.0
github.com/klauspost/compress v1.20.1 github.com/klauspost/compress v1.20.1
@@ -16,7 +15,6 @@ require (
) )
require ( require (
github.com/cloudflare/circl v1.6.3 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect github.com/mr-tron/base58 v1.3.0 // indirect
-4
View File
@@ -1,7 +1,3 @@
github.com/ProtonMail/go-crypto v1.5.2 h1:cucYnvqcY7UOXVD//mSyjeaPY0SSN3v5cDkYPxumINk=
github.com/ProtonMail/go-crypto v1.5.2/go.mod h1:/RaSu30DaKO4RY+XdV/ACcCcZkGr7AhUIduq5sjzzCo=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+12 -21
View File
@@ -21,7 +21,7 @@ import (
"sneak.berlin/go/mfer/mfer" "sneak.berlin/go/mfer/mfer"
) )
// fingerprintHexLen is the length of a full OpenPGP key fingerprint in hex // fingerprintHexLen is the length of a full GPG key fingerprint in hex
// characters. // characters.
const fingerprintHexLen = 40 const fingerprintHexLen = 40
@@ -108,25 +108,18 @@ func (mfa *CLIApp) fetchManifestToTemp(
if tmpErr != nil { if tmpErr != nil {
_ = rc.Close() _ = rc.Close()
return "", tmpErr return "", fmt.Errorf("failed to create temp file: %w", tmpErr)
} }
tmpPath := tmpFile.Name() tmpPath := tmpFile.Name()
_, cpErr := io.Copy(tmpFile, rc)
// Copying stops one byte past mfa.maxManifestSize, which is enough to
// tell that the manifest is too large.
written, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfa.maxManifestSize+1))
_ = rc.Close() _ = rc.Close()
_ = tmpFile.Close() _ = tmpFile.Close()
if cpErr == nil && written > mfa.maxManifestSize {
cpErr = fmt.Errorf("%w of %d bytes", errManifestTooLarge, mfa.maxManifestSize)
}
if cpErr != nil { if cpErr != nil {
_ = mfa.Fs.Remove(tmpPath) _ = mfa.Fs.Remove(tmpPath)
return "", fmt.Errorf("download manifest: %w", cpErr) return "", fmt.Errorf("failed to download manifest: %w", cpErr)
} }
return tmpPath, nil return tmpPath, nil
@@ -142,7 +135,7 @@ func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
_, err := hex.DecodeString(requiredSigner) _, err := hex.DecodeString(requiredSigner)
if err != nil { if err != nil {
return fmt.Errorf("invalid fingerprint: %w", err) return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
} }
if !chk.IsSigned() { if !chk.IsSigned() {
@@ -232,7 +225,7 @@ func findExtraFiles(
err := chk.FindExtraFiles(ctx, extraResults) err := chk.FindExtraFiles(ctx, extraResults)
if err != nil { if err != nil {
return fmt.Errorf("find extra files: %w", err) return fmt.Errorf("failed to check for extra files: %w", err)
} }
<-extraDone <-extraDone
@@ -275,7 +268,7 @@ func runCheck(
progressWg.Wait() progressWg.Wait()
if err != nil { if err != nil {
return 0, fmt.Errorf("check files: %w", err) return 0, fmt.Errorf("check failed: %w", err)
} }
// Wait for results processing to complete // Wait for results processing to complete
@@ -296,18 +289,14 @@ func (mfa *CLIApp) checkManifestOperation(
manifestPath, err := mfa.resolveManifestArg(cmd) manifestPath, err := mfa.resolveManifestArg(cmd)
if err != nil { if err != nil {
return err return fmt.Errorf("check: %w", 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)
// URL manifests need to be downloaded to a temp file for the checker // URL manifests need to be downloaded to a temp file for the checker
if isHTTPURL(manifestPath) { if isHTTPURL(manifestPath) {
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath) tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
if tmpErr != nil { if tmpErr != nil {
return tmpErr return fmt.Errorf("check: %w", tmpErr)
} }
defer func() { _ = mfa.Fs.Remove(tmpPath) }() defer func() { _ = mfa.Fs.Remove(tmpPath) }()
@@ -315,18 +304,20 @@ func (mfa *CLIApp) checkManifestOperation(
manifestPath = tmpPath manifestPath = tmpPath
} }
basePath := cmd.String("base")
showProgress := cmd.Bool("progress") showProgress := cmd.Bool("progress")
log.Infof("checking manifest %s with base %s", manifestPath, basePath) log.Infof("checking manifest %s with base %s", manifestPath, basePath)
// Create checker // Create checker
//nolint:contextcheck // mfer loads a manifest without a context
chk, err := mfer.NewChecker(&mfer.CheckerOptions{ chk, err := mfer.NewChecker(&mfer.CheckerOptions{
ManifestPath: manifestPath, ManifestPath: manifestPath,
BasePath: basePath, BasePath: basePath,
Fs: mfa.Fs, Fs: mfa.Fs,
}) })
if err != nil { if err != nil {
return fmt.Errorf("load manifest: %w", err) return fmt.Errorf("failed to load manifest: %w", err)
} }
// Check signature requirement // Check signature requirement
+8 -10
View File
@@ -5,7 +5,6 @@ import (
"os" "os"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/mfer/mfer"
) )
// NoColor disables colored output when set. Automatically true if the // NoColor disables colored output when set. Automatically true if the
@@ -57,15 +56,14 @@ func Run(appname, version, gitrev string) int {
// RunWithOptions creates and runs the CLI application with the given options. // RunWithOptions creates and runs the CLI application with the given options.
func RunWithOptions(opts *RunOptions) int { func RunWithOptions(opts *RunOptions) int {
m := &CLIApp{ m := &CLIApp{
appname: opts.Appname, appname: opts.Appname,
version: opts.Version, version: opts.Version,
gitrev: opts.Gitrev, gitrev: opts.Gitrev,
exitCode: 0, exitCode: 0,
maxManifestSize: mfer.MaxManifestSize, Stdin: opts.Stdin,
Stdin: opts.Stdin, Stdout: opts.Stdout,
Stdout: opts.Stdout, Stderr: opts.Stderr,
Stderr: opts.Stderr, Fs: opts.Fs,
Fs: opts.Fs,
} }
m.run(opts.Args) m.run(opts.Args)
+5 -301
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -355,44 +354,6 @@ func TestGenerateCommand(t *testing.T) {
assert.True(t, exists) assert.True(t, exists)
} }
// TestGenerateRefusesTwoFilesAtOnePath runs gen on arguments whose files
// would share a path in the manifest: two directories that each hold a.txt,
// and one directory given twice. gen must fail while it lists the files,
// before it hashes any, naming the path, and write no manifest.
func TestGenerateRefusesTwoFilesAtOnePath(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
first, second string
}{
{"two directories", testDir, "/other"},
{"one directory twice", testDir, testDir},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
require.NoError(t, fs.MkdirAll("/other", 0o755))
writeTestFile(t, fs, "/testdir/a.txt", "first")
writeTestFile(t, fs, "/other/a.txt", "second")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", testOutput, tc.first, tc.second,
}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
`enumerate files: duplicate path "a.txt": `+
tc.first+"/a.txt and "+tc.second+"/a.txt")
exists, err := afero.Exists(fs, testOutput)
require.NoError(t, err)
assert.False(t, exists)
})
}
}
// TestGenerateSeededManifestBytes pins the exact bytes `gen --seed` writes // TestGenerateSeededManifestBytes pins the exact bytes `gen --seed` writes
// for a fixed tree, so that a Go or dependency update that changes what // for a fixed tree, so that a Go or dependency update that changes what
// mfer writes fails here. testdata/seeded.mf was written by an mfer built // mfer writes fails here. testdata/seeded.mf was written by an mfer built
@@ -654,10 +615,11 @@ func runCheckAfterRewrite(t *testing.T, rewritten, msg string) {
// TestCheckRequireSignatureRefusesOtherSigningKey runs check // TestCheckRequireSignatureRefusesOtherSigningKey runs check
// --require-signature on a manifest signed by another key whose embedded // --require-signature on a manifest signed by another key whose embedded
// public key block also holds the required key. check must refuse it. // public key block also holds the required key. check must refuse it. It
// needs gpg and is skipped without it, as the other signing tests are.
//
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
func TestCheckRequireSignatureRefusesOtherSigningKey(t *testing.T) { func TestCheckRequireSignatureRefusesOtherSigningKey(t *testing.T) {
t.Parallel()
content := []byte("signed file") content := []byte("signed file")
manifest, required := manifestSignedByAnotherKey(t, manifest, required := manifestSignedByAnotherKey(t,
map[string][]byte{testFileTxt: content}) map[string][]byte{testFileTxt: content})
@@ -674,7 +636,7 @@ func TestCheckRequireSignatureRefusesOtherSigningKey(t *testing.T) {
}, fs) }, fs)
assert.Equal(t, 1, runCLI(opts)) assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), assert.Contains(t, testStderr(t, opts),
"load manifest: "+ "failed to load manifest: signature verification failed: "+
"embedded public key block must hold exactly one key, found 2") "embedded public key block must hold exactly one key, found 2")
} }
@@ -979,90 +941,6 @@ func TestCheckNeverReportsManifest(t *testing.T) {
} }
} }
// The directory setupManifestInSubdir makes and the manifest it writes there,
// relative to the working directory it sets.
const (
testSubdir = "sub"
testSubdirManifest = testSubdir + "/" + defaultManifestName
)
// setupManifestInSubdir makes a temp dir holding file.txt and sub/b.txt,
// where sub/index.mf is the manifest gen writes for sub, and makes it the
// working directory, so a test calling it cannot run in parallel. It returns
// the temp dir.
func setupManifestInSubdir(t *testing.T) string {
t.Helper()
root := t.TempDir()
sub := filepath.Join(root, testSubdir)
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(sub, 0o750))
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "not in the manifest")
writeTestFile(t, fs, filepath.Join(sub, "b.txt"), "in the manifest")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", filepath.Join(sub, defaultManifestName), sub,
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
t.Chdir(root)
return root
}
// TestCheckResolvesEntriesAgainstManifestDirectory runs check from the
// directory above sub, on the manifest in sub. Without --base, the
// manifest's entries are looked for in sub, whether check is given the
// manifest or sub, and the files above sub are not reported. --base names
// the directory to look in instead, the current one included.
//
//nolint:paralleltest // changes the process-global working directory
func TestCheckResolvesEntriesAgainstManifestDirectory(t *testing.T) {
root := setupManifestInSubdir(t)
for _, tc := range []struct {
args []string
exitCode int
failure string // a line check must print, if any
}{
{[]string{testSubdir}, 0, ""},
{[]string{testSubdirManifest}, 0, ""},
{[]string{filepath.Join(root, testSubdir)}, 0, ""},
{[]string{testFlagBase, testSubdir, testSubdirManifest}, 0, ""},
{[]string{testFlagBase, ".", testSubdirManifest}, 1, "MISSING: b.txt"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
opts := testOpts(slices.Concat(
[]string{testApp, cmdCheck, testFlagNoExtra}, tc.args,
), afero.NewOsFs())
assert.Equal(t, tc.exitCode, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStderr(t, opts), tc.failure)
})
}
}
// TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory runs check on
// a manifest given by URL, from a directory holding the file it lists: the
// file is looked for there.
//
//nolint:paralleltest // changes the process-global working directory
func TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory(t *testing.T) {
files := map[string][]byte{testFileTxt: []byte("hello")}
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
defer server.Close()
cwd := chdirTemp(t)
require.NoError(t,
os.WriteFile(filepath.Join(cwd, testFileTxt), files[testFileTxt], 0o600))
opts := testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, 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. // unlistableDirFs is a filesystem on which one directory cannot be listed.
type unlistableDirFs struct { type unlistableDirFs struct {
afero.Fs afero.Fs
@@ -1288,180 +1166,6 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output)) assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output))
} }
// writeTestTree writes file.txt and sub/nested.txt under dir.
func writeTestTree(t *testing.T, fs afero.Fs, dir string) {
t.Helper()
require.NoError(t, fs.MkdirAll(filepath.Join(dir, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(dir, testFileTxt), "hello")
writeTestFile(t, fs, filepath.Join(dir, testSubdir, "nested.txt"), "in sub")
}
// TestGenerateDefaultOutput runs gen without --output on one directory or
// one file: it writes index.mf in that directory, or beside that file,
// listing each file by its path under the directory index.mf is in, and
// check given that directory passes.
func TestGenerateDefaultOutput(t *testing.T) {
t.Parallel()
// Paths are relative to a temp dir holding file.txt and sub/nested.txt.
for name, tc := range map[string]struct {
input, output string
listed []string
}{
"directory": {
".", defaultManifestName, []string{testFileTxt, "sub/nested.txt"},
},
"subdirectory": {testSubdir, testSubdirManifest, []string{"nested.txt"}},
"file": {testFileTxt, defaultManifestName, []string{testFileTxt}},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
fs := afero.NewOsFs()
writeTestTree(t, fs, root)
opts := testOpts([]string{
testApp, cmdGenerate, "-q", filepath.Join(root, tc.input),
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
output := filepath.Join(root, tc.output)
assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output))
opts = testOpts([]string{
testApp, cmdCheck, "-q", filepath.Dir(output),
}, fs)
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
})
}
}
// TestGenerateSeveralPathsDefaultOutput runs gen without --output on two
// directories: it writes index.mf in the current directory, listing both
// directories' files, and writes no index.mf in either directory.
//
//nolint:paralleltest // changes the process-global working directory
func TestGenerateSeveralPathsDefaultOutput(t *testing.T) {
root := t.TempDir()
fs := afero.NewOsFs()
dirs := []string{"first", "second"}
for _, dir := range dirs {
require.NoError(t, fs.MkdirAll(filepath.Join(root, dir), 0o750))
writeTestFile(t, fs, filepath.Join(root, dir, dir+".txt"), dir)
}
t.Chdir(root)
opts := testOpts(append([]string{testApp, cmdGenerate, "-q"}, dirs...), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, []string{"first.txt", "second.txt"},
manifestPaths(t, fs, filepath.Join(root, defaultManifestName)))
for _, dir := range dirs {
exists, err := afero.Exists(fs, filepath.Join(root, dir, defaultManifestName))
require.NoError(t, err)
assert.False(t, exists, "index.mf written in %s", dir)
}
}
// TestGenerateBytesDoNotDependOnOutput runs gen --seed on one tree, each
// time writing to another file, over a file already there and beside an
// earlier run's temp file: neither is listed, and what is listed depends
// only on the tree, so every manifest has the same bytes.
func TestGenerateBytesDoNotDependOnOutput(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
defaultOutput := filepath.Join(tree, defaultManifestName)
fs := afero.NewOsFs()
writeTestTree(t, fs, tree)
var first []byte
for _, output := range []string{
defaultOutput,
filepath.Join(tree, "listing.mf"),
filepath.Join(tree, testSubdir, "listing.mf"),
filepath.Join(root, "outside.mf"),
} {
writeTestFile(t, fs, output, "previous manifest")
writeTestFile(t, fs, manifestTempPath(output), "part of a manifest")
args := []string{testApp, cmdGenerate, "-q", "-f", "--seed", "mfer"}
if output != defaultOutput {
args = append(args, "-o", output)
}
args = append(args, tree)
opts := testOpts(args, fs)
require.Equal(t, 0, runCLI(opts),
"output %s, stderr: %s", output, testStderr(t, opts))
got, err := afero.ReadFile(fs, output)
require.NoError(t, err)
require.NoError(t, fs.Remove(output))
if first == nil {
first = got
}
assert.Equal(t, first, got, "output %s", output)
}
}
// TestGenerateRefusesExistingDefaultOutput runs gen without --output or
// --force on a directory already holding index.mf: gen fails, naming that
// file, and leaves it as it was.
func TestGenerateRefusesExistingDefaultOutput(t *testing.T) {
t.Parallel()
output := filepath.Join(testDir, defaultManifestName)
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", testDir}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
"output file "+output+" already exists (use --force to overwrite)")
content, err := afero.ReadFile(fs, output)
require.NoError(t, err)
assert.Equal(t, "previous manifest", string(content))
}
// TestGenerateRefusesEmptyOutput runs gen with --force and an --output
// given an empty value, as an unset shell variable gives it, on a
// directory already holding index.mf: gen fails and leaves that file as it
// was.
func TestGenerateRefusesEmptyOutput(t *testing.T) {
t.Parallel()
output := filepath.Join(testDir, defaultManifestName)
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-f", "-o", "", testDir}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), errEmptyOutput.Error())
content, err := afero.ReadFile(fs, output)
require.NoError(t, err)
assert.Equal(t, "previous manifest", string(content))
}
func TestGenerateAtomicWriteUsesTemp(t *testing.T) { func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
t.Parallel() t.Parallel()
+41 -146
View File
@@ -4,18 +4,14 @@ package cli
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/hex"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -112,10 +108,11 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
// manifest. The signing key's fingerprint is whatever the generated key // manifest. The signing key's fingerprint is whatever the generated key
// produced, so it is read back from the checker and substituted into the // produced, so it is read back from the checker and substituted into the
// expected string; the required signer is a fixed value that cannot match // expected string; the required signer is a fixed value that cannot match
// it. // it. Requires gpg and is skipped where it is absent, as the other signing
// tests are.
//
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
func TestSignerMismatchMessage(t *testing.T) { func TestSignerMismatchMessage(t *testing.T) {
t.Parallel()
chk := signedChecker(t, chk := signedChecker(t,
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")})) signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
@@ -126,42 +123,43 @@ func TestSignerMismatchMessage(t *testing.T) {
" does not match required "+msgFpB) " does not match required "+msgFpB)
} }
// testSecretKey returns a new OpenPGP key with its secret key, armored, as // signedManifest returns a manifest of files signed by a throwaway GPG key
// gpg --export-secret-keys --armor writes it, and the key's fingerprint. // generated in a temporary GNUPGHOME, which it leaves set for the rest of
// The key is protected by passphrase unless that is nil. It is an Ed25519 // the test.
// key, which is quick to make.
func testSecretKey(t *testing.T, passphrase []byte) ([]byte, string) {
t.Helper()
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test",
&packet.Config{Algorithm: packet.PubKeyAlgoEdDSA})
require.NoError(t, err)
if passphrase != nil {
require.NoError(t, key.EncryptPrivateKeys(passphrase, nil))
}
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
require.NoError(t, err)
require.NoError(t, key.SerializePrivateWithoutSigning(w, nil))
require.NoError(t, w.Close())
return buf.Bytes(), strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
}
// signedManifest returns a manifest of files signed by a new OpenPGP key.
func signedManifest(t *testing.T, files map[string][]byte) []byte { func signedManifest(t *testing.T, files map[string][]byte) []byte {
t.Helper() t.Helper()
secretKey, _ := testSecretKey(t, nil) _, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not installed, skipping signing test")
}
gpgHome := t.TempDir()
params := "%no-protection\n" +
"Key-Type: RSA\nKey-Length: 2048\n" +
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n" +
"Expire-Date: 0\n%commit\n"
paramsFile := filepath.Join(gpgHome, "key-params")
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
cmd := exec.CommandContext(context.Background(), "gpg",
"--batch", "--gen-key", paramsFile)
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
out, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("failed to generate test GPG key: %v: %s", err, out)
}
t.Setenv("GNUPGHOME", gpgHome)
b := mfer.NewBuilder() b := mfer.NewBuilder()
b.SetSigningOptions(&mfer.SigningOptions{SecretKey: secretKey}) b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
for path, content := range files { for path, content := range files {
_, err := b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)), _, err = b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)),
mfer.ModTime{}, 0, bytes.NewReader(content), nil) mfer.ModTime{}, 0, bytes.NewReader(content), nil)
require.NoError(t, err) require.NoError(t, err)
} }
@@ -192,8 +190,8 @@ func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
} }
// manifestSignedByAnotherKey returns a manifest of files and the // manifestSignedByAnotherKey returns a manifest of files and the
// fingerprint of a new key, the required key, that did not sign it. // fingerprint of a throwaway key, the required key, that did not sign it.
// The manifest is signed by a second new key; its embedded public key // The manifest is signed by a second throwaway key; its embedded public key
// block holds the required key followed by the second key, and its signer // block holds the required key followed by the second key, and its signer
// field names the required key. // field names the required key.
func manifestSignedByAnotherKey( func manifestSignedByAnotherKey(
@@ -209,10 +207,8 @@ func manifestSignedByAnotherKey(
require.NoError(t, proto.Unmarshal( require.NoError(t, proto.Unmarshal(
signedManifest(t, files)[len(mfer.MAGIC):], outer)) signedManifest(t, files)[len(mfer.MAGIC):], outer))
// Armored blocks start on a line of their own; mfer, unlike gpg, ends
// one without a newline.
outer.SigningPubKey = slices.Concat( outer.SigningPubKey = slices.Concat(
required.GetSigningPubKey(), []byte("\n"), outer.GetSigningPubKey()) required.GetSigningPubKey(), outer.GetSigningPubKey())
outer.Signer = required.GetSigner() outer.Signer = required.GetSigner()
data, err := proto.Marshal(outer) data, err := proto.Marshal(outer)
@@ -307,7 +303,7 @@ func TestManifestLoaderHTTPStatusMessage(t *testing.T) {
_, err := mfa.openManifestReader(context.Background(), server.URL+"/foo.mf") _, err := mfa.openManifestReader(context.Background(), server.URL+"/foo.mf")
require.ErrorIs(t, err, errHTTPStatus) require.ErrorIs(t, err, errHTTPStatus)
assert.EqualError(t, err, assert.EqualError(t, err,
"download manifest "+server.URL+"/foo.mf: unexpected HTTP status 404") "failed to fetch "+server.URL+"/foo.mf: HTTP 404")
} }
func TestFetchManifestHTTPStatusMessage(t *testing.T) { func TestFetchManifestHTTPStatusMessage(t *testing.T) {
@@ -329,7 +325,7 @@ func TestFetchManifestHTTPStatusMessage(t *testing.T) {
return cmd.Run(context.Background(), []string{cmdFetch, server.URL}) return cmd.Run(context.Background(), []string{cmdFetch, server.URL})
}) })
require.ErrorIs(t, err, errHTTPStatus) require.ErrorIs(t, err, errHTTPStatus)
assert.EqualError(t, err, "download manifest: unexpected HTTP status 404") assert.EqualError(t, err, "failed to fetch manifest: HTTP 404")
} }
func TestFetchFileHTTPStatusMessage(t *testing.T) { func TestFetchFileHTTPStatusMessage(t *testing.T) {
@@ -347,108 +343,7 @@ func TestFetchFileHTTPStatusMessage(t *testing.T) {
&mfer.MFFilePath{}, nil) &mfer.MFFilePath{}, nil)
}) })
require.ErrorIs(t, err, errHTTPStatus) require.ErrorIs(t, err, errHTTPStatus)
assert.EqualError(t, err, "unexpected HTTP status 500") assert.EqualError(t, err, "HTTP 500")
}
// TestCheckCorruptManifestMessage runs check on a file that is not a
// manifest.
func TestCheckCorruptManifestMessage(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
mfa := &CLIApp{Fs: fs}
cmd := mfa.checkCommand()
cmd.Action = mfa.checkManifestOperation
// checkManifestOperation logs to the process-global logger.
err := runLocked(func() error {
return cmd.Run(context.Background(), []string{cmdCheck, "/bad.mf"})
})
assert.EqualError(t, err, "load manifest: invalid file format")
}
// TestListMissingManifestMessage runs list on a manifest file that does not
// exist.
func TestListMissingManifestMessage(t *testing.T) {
t.Parallel()
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
cmd := mfa.listCommand()
// listManifestOperation sets the process-global log level.
err := runLocked(func() error {
return cmd.Run(context.Background(), []string{cmdList, "/nope.mf"})
})
require.ErrorIs(t, err, os.ErrNotExist)
assert.EqualError(t, err, "open /nope.mf: file does not exist")
}
// TestFetchHashMismatchMessage runs fetch against a server that sends a
// listed file with other content of the same size.
func TestFetchHashMismatchMessage(t *testing.T) {
t.Parallel()
manifest := builtManifest(t, map[string][]byte{testFileTxt: []byte("listed")})
server := httptest.NewServer(fetchTestHandler(manifest,
map[string][]byte{testFileTxt: []byte("served")}))
defer server.Close()
mfa := &CLIApp{Fs: afero.NewMemMapFs(), maxManifestSize: mfer.MaxManifestSize}
cmd := mfa.fetchCommand()
cmd.Action = mfa.fetchManifestOperation
// fetchManifestOperation logs to the process-global logger.
err := runLocked(func() error {
return cmd.Run(context.Background(),
[]string{cmdFetch, "--" + flagDest, t.TempDir(), server.URL})
})
require.ErrorIs(t, err, errHashMismatch)
assert.EqualError(t, err, "download "+testFileTxt+": hash mismatch")
}
// TestFreshenBackslashPathMessage runs freshen on a tree that has gained a
// file whose name holds a backslash, which a manifest path may not contain.
func TestFreshenBackslashPathMessage(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
root, manifestPath := setupFreshenDir(t, fs,
map[string]string{testFileTxt: "content"})
writeTestFile(t, fs, filepath.Join(root, `a\b.txt`), "new")
mfa := &CLIApp{Fs: fs}
cmd := mfa.freshenCommand()
cmd.Action = mfa.freshenManifestOperation
// freshenManifestOperation logs to the process-global logger.
err := runLocked(func() error {
return cmd.Run(context.Background(),
[]string{cmdFreshen, testFlagBase, root, manifestPath})
})
assert.EqualError(t, err,
`path "a\\b.txt" contains backslash; use forward slashes only`)
}
// TestFreshenReadErrorMessage has freshen hash a directory as though it
// were a file, so reading it fails.
func TestFreshenReadErrorMessage(t *testing.T) {
t.Parallel()
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, "sub"), 0o750))
hasher := &freshenHasher{
fs: afero.NewOsFs(),
absBase: root,
builder: mfer.NewBuilder(),
}
err := hasher.processEntry(&freshenEntry{path: "sub", needsHash: true})
assert.EqualError(t, err,
"read "+filepath.Join(root, "sub")+": is a directory")
} }
func TestURLRequiredMessage(t *testing.T) { func TestURLRequiredMessage(t *testing.T) {
+5 -4
View File
@@ -26,19 +26,20 @@ func (mfa *CLIApp) exportManifestOperation(
) error { ) error {
pathOrURL, err := mfa.resolveManifestArg(cmd) pathOrURL, err := mfa.resolveManifestArg(cmd)
if err != nil { if err != nil {
return err return fmt.Errorf("export: %w", err)
} }
rc, err := mfa.openManifestReader(ctx, pathOrURL) rc, err := mfa.openManifestReader(ctx, pathOrURL)
if err != nil { if err != nil {
return err return fmt.Errorf("export: %w", err)
} }
defer func() { _ = rc.Close() }() defer func() { _ = rc.Close() }()
//nolint:contextcheck // mfer loads a manifest without a context
manifest, err := mfer.NewManifestFromReader(rc) manifest, err := mfer.NewManifestFromReader(rc)
if err != nil { if err != nil {
return fmt.Errorf("parse manifest: %w", err) return fmt.Errorf("export: failed to parse manifest: %w", err)
} }
files := manifest.Files() files := manifest.Files()
@@ -75,7 +76,7 @@ func (mfa *CLIApp) exportManifestOperation(
err = enc.Encode(entries) err = enc.Encode(entries)
if err != nil { if err != nil {
return fmt.Errorf("encode JSON: %w", err) return fmt.Errorf("export: failed to encode JSON: %w", err)
} }
return nil return nil
+28 -30
View File
@@ -239,7 +239,7 @@ func reportDownloadProgress(progress <-chan DownloadProgress, done chan<- struct
func manifestBaseURL(manifestURL string) (*url.URL, error) { func manifestBaseURL(manifestURL string) (*url.URL, error) {
parsed, err := url.Parse(manifestURL) parsed, err := url.Parse(manifestURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid manifest URL: %w", err) return nil, fmt.Errorf("fetch: invalid manifest URL: %w", err)
} }
// JoinPath cleans the path it builds, so ".." drops the manifest's // JoinPath cleans the path it builds, so ".." drops the manifest's
@@ -268,7 +268,7 @@ func downloadManifestFiles(
// Sanitize the path to prevent path traversal attacks // Sanitize the path to prevent path traversal attacks
localPath, err := sanitizePath(f.GetPath()) localPath, err := sanitizePath(f.GetPath())
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("invalid file entry: %w", err) return 0, 0, fmt.Errorf("invalid path in manifest: %w", err)
} }
if alreadyPresent(dest, localPath, f) { if alreadyPresent(dest, localPath, f) {
@@ -284,7 +284,7 @@ func downloadManifestFiles(
err = downloadFile(ctx, client, fileURL, dest, localPath, f, progress) err = downloadFile(ctx, client, fileURL, dest, localPath, f, progress)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("download %s: %w", f.GetPath(), err) return 0, 0, fmt.Errorf("failed to download %s: %w", f.GetPath(), err)
} }
downloaded++ downloaded++
@@ -361,7 +361,7 @@ func (mfa *CLIApp) fetchManifestOperation(
firstDelay: firstRetryDelay, firstDelay: firstRetryDelay,
} }
manifestData, files, err := mfa.fetchManifest(ctx, cmd, client, manifestURL) manifestData, files, err := fetchManifest(ctx, cmd, client, manifestURL)
if err != nil { if err != nil {
return err return err
} }
@@ -376,7 +376,7 @@ func (mfa *CLIApp) fetchManifestOperation(
err = os.MkdirAll(dest, dirPerms) err = os.MkdirAll(dest, dirPerms)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create destination directory %s: %w", dest, err)
} }
// Create progress channel and start progress reporter goroutine // Create progress channel and start progress reporter goroutine
@@ -403,7 +403,7 @@ func (mfa *CLIApp) fetchManifestOperation(
// "mfer check" can verify the tree later. // "mfer check" can verify the tree later.
err = saveManifest(dest, manifestData) err = saveManifest(dest, manifestData)
if err != nil { if err != nil {
return fmt.Errorf("save manifest: %w", err) return fmt.Errorf("failed to save manifest: %w", err)
} }
// Print summary // Print summary
@@ -426,42 +426,37 @@ func (mfa *CLIApp) fetchManifestOperation(
// that lists a file where fetch writes another or a mode outside 0777. It // 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 // returns the manifest as downloaded, to be saved once the files are in
// place, and the files it lists. // place, and the files it lists.
func (mfa *CLIApp) fetchManifest( func fetchManifest(
ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string, ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string,
) ([]byte, []*mfer.MFFilePath, error) { ) ([]byte, []*mfer.MFFilePath, error) {
log.Infof("fetching manifest from %s", manifestURL) log.Infof("fetching manifest from %s", manifestURL)
// Read the whole manifest before parsing it, so that a connection // Read the whole manifest before parsing it, so that a connection
// lost partway through is retried rather than reported as a bad // lost partway through is retried rather than reported as a bad
// manifest. Reading stops one byte past mfa.maxManifestSize, which is // manifest.
// enough to tell that the manifest is too large.
var manifestData []byte var manifestData []byte
err := client.get(ctx, manifestURL, func(resp *http.Response) error { err := client.get(ctx, manifestURL, func(resp *http.Response) error {
var readErr error var readErr error
manifestData, readErr = io.ReadAll( manifestData, readErr = io.ReadAll(resp.Body)
io.LimitReader(resp.Body, mfa.maxManifestSize+1))
return readErr return readErr
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("download manifest: %w", err) return nil, nil, fmt.Errorf("failed to fetch manifest: %w", err)
}
if int64(len(manifestData)) > mfa.maxManifestSize {
return nil, nil, fmt.Errorf("download manifest: %w of %d bytes",
errManifestTooLarge, mfa.maxManifestSize)
} }
// Parse manifest // Parse manifest
//nolint:contextcheck // mfer loads a manifest without a context
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData)) manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("parse manifest: %w", err) return nil, nil, fmt.Errorf("failed to parse manifest: %w", err)
} }
requiredSigner := cmd.String(flagRequireSignature) requiredSigner := cmd.String(flagRequireSignature)
if requiredSigner != "" { if requiredSigner != "" {
//nolint:contextcheck // mfer loads a manifest without a context
err = verifyFetchedSigner(manifestData, requiredSigner) err = verifyFetchedSigner(manifestData, requiredSigner)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -559,7 +554,7 @@ func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
Fs: memFs, Fs: memFs,
}) })
if err != nil { if err != nil {
return fmt.Errorf("load manifest: %w", err) return fmt.Errorf("failed to load manifest: %w", err)
} }
return verifyRequiredSigner(chk, requiredSigner) return verifyRequiredSigner(chk, requiredSigner)
@@ -651,7 +646,7 @@ func checkNoSymlinks(dest, p string) error {
} }
if err != nil { if err != nil {
return err return fmt.Errorf("failed to check %s for a symlink: %w", current, err)
} }
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
@@ -768,7 +763,7 @@ func tempPathFor(localPath string) string {
func verifyDownloadedHash(digest []byte, entry *mfer.MFFilePath) error { func verifyDownloadedHash(digest []byte, entry *mfer.MFFilePath) error {
computed, err := multihash.Encode(digest, multihash.SHA2_256) computed, err := multihash.Encode(digest, multihash.SHA2_256)
if err != nil { if err != nil {
return fmt.Errorf("encode hash: %w", err) return fmt.Errorf("failed to encode hash: %w", err)
} }
for _, hash := range entry.GetHashes() { for _, hash := range entry.GetHashes() {
@@ -795,7 +790,7 @@ func downloadFile(
// so every entry point to downloadFile gets the same treatment. // so every entry point to downloadFile gets the same treatment.
localPath, err := sanitizePath(localPath) localPath, err := sanitizePath(localPath)
if err != nil { if err != nil {
return fmt.Errorf("invalid file entry: %w", err) return fmt.Errorf("invalid path: %w", err)
} }
// Create parent directories if needed // Create parent directories if needed
@@ -810,7 +805,7 @@ func downloadFile(
err = os.MkdirAll(dir, dirPerms) err = os.MkdirAll(dir, dirPerms)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create directory %s: %w", dir, err)
} }
} }
@@ -844,7 +839,7 @@ func createTempFile(dest, tmpPath string) (*os.File, error) {
out, err := os.OpenFile( //nolint:gosec // G304: see comment above out, err := os.OpenFile( //nolint:gosec // G304: see comment above
path, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms) path, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to create temp file: %w", err)
} }
return out, nil return out, nil
@@ -857,7 +852,12 @@ func moveIntoPlace(dest, tmpPath, localPath string) error {
return err return err
} }
return os.Rename(filepath.Join(dest, tmpPath), filepath.Join(dest, localPath)) err = os.Rename(filepath.Join(dest, tmpPath), filepath.Join(dest, localPath))
if err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
} }
// saveResponse writes resp's body to tmpPath, verifies it against entry, // saveResponse writes resp's body to tmpPath, verifies it against entry,
@@ -892,7 +892,7 @@ func saveResponse(
_ = out.Close() _ = out.Close()
_ = os.Remove(filepath.Join(dest, tmpPath)) _ = os.Remove(filepath.Join(dest, tmpPath))
return err return fmt.Errorf("failed to set mode: %w", err)
} }
} }
@@ -908,10 +908,8 @@ func saveResponse(
progress: progress, progress: progress,
} }
// Copy content while hashing and reporting progress. One byte past // Copy content while hashing and reporting progress
// the listed size is enough for finishDownload to report a size written, copyErr := io.Copy(pw, resp.Body)
// mismatch.
written, copyErr := io.Copy(pw, io.LimitReader(resp.Body, expectedSize+1))
// Close file before checking errors (to flush writes) // Close file before checking errors (to flush writes)
closeErr := out.Close() closeErr := out.Close()
+13 -90
View File
@@ -26,7 +26,6 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
urfcli "github.com/urfave/cli/v3"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"sneak.berlin/go/mfer/mfer" "sneak.berlin/go/mfer/mfer"
) )
@@ -442,76 +441,6 @@ func TestFetchSizeMismatch(t *testing.T) {
"temp file should be cleaned up on size mismatch") "temp file should be cleaned up on size mismatch")
} }
// zeros is an io.Reader of zero bytes without end.
type zeros struct{}
func (zeros) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
// TestFetchStopsReadingFilePastListedSize serves a body that never ends
// for a file listed at 16 bytes. fetch must stop reading one byte past
// the listed size, report the size mismatch and remove its temp file.
//
//nolint:paralleltest // changes the process-global working directory
func TestFetchStopsReadingFilePastListedSize(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.Copy(w, zeros{})
}))
defer server.Close()
chdirTemp(t)
err := downloadFile(context.Background(), testClient(),
server.URL+"/"+testFileTxt, ".", testFileTxt,
&mfer.MFFilePath{Path: testFileTxt, Size: 16}, nil)
require.ErrorIs(t, err, errSizeMismatch)
require.EqualError(t, err, "size mismatch: expected 16 bytes, got 17")
assert.NoFileExists(t, tempPathFor(testFileTxt))
}
// 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, fail with an error
// naming it and leave no temp file.
func TestManifestDownloadStopsAtLimit(t *testing.T) {
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()
fetch.Action = mfa.fetchManifestOperation
check := mfa.checkCommand()
check.Action = mfa.checkManifestOperation
for _, cmd := range []*urfcli.Command{fetch, check} {
// Both operations log to the process-global logger.
err := runLocked(func() error {
return cmd.Run(context.Background(),
[]string{cmd.Name, server.URL + "/index.mf"})
})
require.ErrorIs(t, err, errManifestTooLarge, cmd.Name)
require.EqualError(t, err,
"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 //nolint:paralleltest // changes the process-global working directory
func TestFetchProgress(t *testing.T) { func TestFetchProgress(t *testing.T) {
// Create source filesystem with a larger test file // Create source filesystem with a larger test file
@@ -606,27 +535,27 @@ func TestFetchRefusesSymlinks(t *testing.T) {
}{ }{
{ {
"parent directory", "sub/deeper/file.txt", "sub", ".", "parent directory", "sub/deeper/file.txt", "sub", ".",
"download sub/deeper/file.txt", "failed to download sub/deeper/file.txt",
}, },
{ {
"directory inside a plain directory", "docs/data/passwd", "docs/data", ".", "directory inside a plain directory", "docs/data/passwd", "docs/data", ".",
"download docs/data/passwd", "failed to download docs/data/passwd",
}, },
{ {
"temp file", testFileTxt, ".file.txt.tmp", newFile, "temp file", testFileTxt, ".file.txt.tmp", newFile,
"download " + testFileTxt, "failed to download " + testFileTxt,
}, },
{ {
"file", testFileTxt, testFileTxt, newFile, "file", testFileTxt, testFileTxt, newFile,
"download " + testFileTxt, "failed to download " + testFileTxt,
}, },
{ {
"manifest temp file", testFileTxt, tempPathFor(defaultManifestName), newFile, "manifest temp file", testFileTxt, tempPathFor(defaultManifestName), newFile,
"save manifest", "failed to save manifest",
}, },
{ {
"manifest", testFileTxt, defaultManifestName, newFile, "manifest", testFileTxt, defaultManifestName, newFile,
"save manifest", "failed to save manifest",
}, },
} }
@@ -693,7 +622,7 @@ func TestFetchDoesNotSkipThroughSymlink(t *testing.T) {
}, afero.NewOsFs()) }, afero.NewOsFs())
assert.Equal(t, 1, runCLI(opts)) assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), assert.Contains(t, testStderr(t, opts),
"download sub/"+testFileTxt+": symlink in path not allowed: "+link) "failed to download sub/"+testFileTxt+": symlink in path not allowed: "+link)
assert.Equal(t, map[string][]byte{testFileTxt: content}, filesUnder(t, outside)) assert.Equal(t, map[string][]byte{testFileTxt: content}, filesUnder(t, outside))
} }
@@ -763,8 +692,7 @@ func TestGetRetriesTransientStatusesOnly(t *testing.T) {
err := getNothing(testClient(), server.URL, 10*time.Second) err := getNothing(testClient(), server.URL, 10*time.Second)
require.ErrorIs(t, err, errHTTPStatus) require.ErrorIs(t, err, errHTTPStatus)
require.EqualError(t, err, require.EqualError(t, err, fmt.Sprintf("HTTP %d", tt.status))
fmt.Sprintf("unexpected HTTP status %d", tt.status))
assert.Equal(t, tt.requests, requests.Load()) assert.Equal(t, tt.requests, requests.Load())
}) })
} }
@@ -1191,23 +1119,20 @@ func TestFetchIntoDest(t *testing.T) {
// with check's message before it downloads or writes anything; the // with check's message before it downloads or writes anything; the
// required key lets it through. A manifest signed by another key whose // required key lets it through. A manifest signed by another key whose
// embedded public key block also holds the required key must stop fetch // embedded public key block also holds the required key must stop fetch
// too. // too. The signed cases need gpg and are skipped without it, as the other
// signing tests are.
//
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
func TestFetchRequireSignature(t *testing.T) { func TestFetchRequireSignature(t *testing.T) {
t.Parallel()
files := map[string][]byte{testFileTxt: []byte("signed file")} files := map[string][]byte{testFileTxt: []byte("signed file")}
t.Run("unsigned", func(t *testing.T) { t.Run("unsigned", func(t *testing.T) {
t.Parallel()
assertFetchRefused(t, manifestOf(t, files), files, assertFetchRefused(t, manifestOf(t, files), files,
"manifest is not signed, but signature from "+msgFpA+" is required", "manifest is not signed, but signature from "+msgFpA+" is required",
"--"+flagRequireSignature, msgFpA) "--"+flagRequireSignature, msgFpA)
}) })
t.Run("signed", func(t *testing.T) { t.Run("signed", func(t *testing.T) {
t.Parallel()
manifest := signedManifest(t, files) manifest := signedManifest(t, files)
signer := string(signedChecker(t, manifest).Signer()) signer := string(signedChecker(t, manifest).Signer())
@@ -1230,12 +1155,10 @@ func TestFetchRequireSignature(t *testing.T) {
}) })
t.Run("signed by another key embedded after the required one", func(t *testing.T) { t.Run("signed by another key embedded after the required one", func(t *testing.T) {
t.Parallel()
manifest, required := manifestSignedByAnotherKey(t, files) manifest, required := manifestSignedByAnotherKey(t, files)
assertFetchRefused(t, manifest, files, assertFetchRefused(t, manifest, files,
"parse manifest: "+ "failed to parse manifest: signature verification failed: "+
"embedded public key block must hold exactly one key, found 2", "embedded public key block must hold exactly one key, found 2",
"--"+flagRequireSignature, required) "--"+flagRequireSignature, required)
}) })
+43 -35
View File
@@ -165,7 +165,8 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
// Get relative path // Get relative path
relPath, err := filepath.Rel(s.absBase, path) relPath, err := filepath.Rel(s.absBase, path)
if err != nil { if err != nil {
return err return fmt.Errorf(
"freshen: failed to compute relative path for %s: %w", path, err)
} }
// Handle dotfiles // Handle dotfiles
@@ -279,8 +280,13 @@ func (h *freshenHasher) reportProgress(n int64) {
// processEntry hashes the entry if needed and adds it to the builder. // processEntry hashes the entry if needed and adds it to the builder.
func (h *freshenHasher) processEntry(e *freshenEntry) error { func (h *freshenHasher) processEntry(e *freshenEntry) error {
if !e.needsHash { if !e.needsHash {
// Use existing entry; the error names the entry // Use existing entry
return addExistingToBuilder(h.builder, e.existing) err := addExistingToBuilder(h.builder, e.existing)
if err != nil {
return fmt.Errorf("failed to add %s: %w", e.path, err)
}
return nil
} }
// Need to read and hash the file // Need to read and hash the file
@@ -288,21 +294,26 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error {
f, err := h.fs.Open(absPath) f, err := h.fs.Open(absPath)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to open %s: %w", e.path, err)
} }
hash, bytesRead, err := hashFile(f, h.reportProgress) hash, bytesRead, err := hashFile(f, h.reportProgress)
_ = f.Close() _ = f.Close()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to hash %s: %w", e.path, err)
} }
h.hashedBytes += bytesRead h.hashedBytes += bytesRead
h.hashedFiles++ h.hashedFiles++
// Add to builder with computed hash; a refused path is named in the error // Add to builder with computed hash
return addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash) err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash)
if err != nil {
return fmt.Errorf("failed to add %s: %w", e.path, err)
}
return nil
} }
// writeFreshenedManifest writes the manifest atomically (write to a // writeFreshenedManifest writes the manifest atomically (write to a
@@ -314,7 +325,7 @@ func writeFreshenedManifest(
outFile, err := afs.Create(tmpPath) outFile, err := afs.Create(tmpPath)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create temp file: %w", err)
} }
err = builder.Build(ctx, outFile) err = builder.Build(ctx, outFile)
@@ -323,7 +334,7 @@ func writeFreshenedManifest(
if err != nil { if err != nil {
_ = afs.Remove(tmpPath) _ = afs.Remove(tmpPath)
return fmt.Errorf("build manifest: %w", err) return fmt.Errorf("failed to write manifest: %w", err)
} }
// Rename temp to final // Rename temp to final
@@ -331,7 +342,7 @@ func writeFreshenedManifest(
if err != nil { if err != nil {
_ = afs.Remove(tmpPath) _ = afs.Remove(tmpPath)
return err return fmt.Errorf("failed to rename manifest: %w", err)
} }
return nil return nil
@@ -339,7 +350,7 @@ func writeFreshenedManifest(
// newFreshenBuilder constructs the manifest builder configured from CLI // newFreshenBuilder constructs the manifest builder configured from CLI
// flags. // flags.
func (mfa *CLIApp) newFreshenBuilder(cmd *cli.Command) (*mfer.Builder, error) { func newFreshenBuilder(cmd *cli.Command) *mfer.Builder {
builder := mfer.NewBuilder() builder := mfer.NewBuilder()
if cmd.Bool("include-timestamps") { if cmd.Bool("include-timestamps") {
builder.SetIncludeTimestamps(true) builder.SetIncludeTimestamps(true)
@@ -347,15 +358,13 @@ func (mfa *CLIApp) newFreshenBuilder(cmd *cli.Command) (*mfer.Builder, error) {
// Set up signing options if sign-key is provided // Set up signing options if sign-key is provided
if signKey := cmd.String("sign-key"); signKey != "" { if signKey := cmd.String("sign-key"); signKey != "" {
signing, err := mfa.signingOptions(signKey) builder.SetSigningOptions(&mfer.SigningOptions{
if err != nil { KeyID: mfer.GPGKeyID(signKey),
return nil, err })
} log.Infof("signing manifest with GPG key: %s", signKey)
builder.SetSigningOptions(signing)
} }
return builder, nil return builder
} }
// freshenScan runs the scan phase against the loaded manifest entries // freshenScan runs the scan phase against the loaded manifest entries
@@ -399,7 +408,7 @@ func (mfa *CLIApp) freshenScan(
} }
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("scan filesystem: %w", err) return nil, 0, fmt.Errorf("failed to scan filesystem: %w", err)
} }
// Remaining entries in existingByPath are removed files // Remaining entries in existingByPath are removed files
@@ -435,7 +444,7 @@ func hashTotals(entries []*freshenEntry) (int64, int64) {
} }
// runFreshenHash processes every entry through the hasher, aborting if // runFreshenHash processes every entry through the hasher, aborting if
// the context is canceled, and ends the hasher's progress line. // the context is canceled.
func runFreshenHash( func runFreshenHash(
ctx context.Context, hasher *freshenHasher, entries []*freshenEntry, ctx context.Context, hasher *freshenHasher, entries []*freshenEntry,
) error { ) error {
@@ -452,10 +461,6 @@ func runFreshenHash(
} }
} }
if hasher.showProgress && hasher.filesToHash > 0 {
log.ProgressDone()
}
return nil return nil
} }
@@ -472,7 +477,7 @@ func (mfa *CLIApp) loadExistingEntries(
Fs: mfa.Fs, Fs: mfa.Fs,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("load manifest: %w", err) return nil, fmt.Errorf("failed to load manifest: %w", err)
} }
existingFiles := manifest.Files() existingFiles := manifest.Files()
@@ -492,27 +497,24 @@ func (mfa *CLIApp) freshenManifestOperation(
) error { ) error {
log.Debug("freshenManifestOperation()") log.Debug("freshenManifestOperation()")
basePath := cmd.String("base")
showProgress := cmd.Bool("progress") showProgress := cmd.Bool("progress")
// Find manifest file // Find manifest file
manifestPath, err := mfa.resolveFreshenManifestPath(cmd) manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
if err != nil { if err != nil {
return err return fmt.Errorf("freshen: %w", err)
}
builder, err := mfa.newFreshenBuilder(cmd)
if err != nil {
return err
} }
//nolint:contextcheck // mfer loads a manifest without a context
existingByPath, err := mfa.loadExistingEntries(manifestPath) existingByPath, err := mfa.loadExistingEntries(manifestPath)
if err != nil { if err != nil {
return err return err
} }
absBase, err := filepath.Abs(resolveBasePath(cmd, manifestPath)) absBase, err := filepath.Abs(basePath)
if err != nil { if err != nil {
return fmt.Errorf("invalid base path: %w", err) return fmt.Errorf("freshen: invalid base path: %w", err)
} }
// Phase 1: Scan filesystem // Phase 1: Scan filesystem
@@ -538,7 +540,7 @@ func (mfa *CLIApp) freshenManifestOperation(
totalHashBytes: totalHashBytes, totalHashBytes: totalHashBytes,
filesToHash: filesToHash, filesToHash: filesToHash,
startHash: time.Now(), startHash: time.Now(),
builder: builder, builder: newFreshenBuilder(cmd),
} }
err = runFreshenHash(ctx, hasher, scanner.entries) err = runFreshenHash(ctx, hasher, scanner.entries)
@@ -546,6 +548,10 @@ func (mfa *CLIApp) freshenManifestOperation(
return err return err
} }
if showProgress && filesToHash > 0 {
log.ProgressDone()
}
// Print summary // Print summary
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed", log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
scanner.unchanged, scanner.changed, scanner.added, removed) scanner.unchanged, scanner.changed, scanner.added, removed)
@@ -600,7 +606,9 @@ func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
break break
} }
// Returned unwrapped: a read error already names the file. // Returned unwrapped: the caller renders this as
// "failed to hash <path>: <err>" and adding a second layer here
// would change that message.
if err != nil { if err != nil {
return nil, total, err return nil, total, err
} }
-36
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
@@ -301,41 +300,6 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
manifestPaths(t, fs, manifestPath)) manifestPaths(t, fs, manifestPath))
} }
// TestFreshenResolvesEntriesAgainstManifestDirectory adds sub/c.txt, then
// runs freshen from the directory above sub, on the manifest in sub.
// Without --base, the manifest then lists the files in sub, whether freshen
// is given the manifest or sub. --base names the directory to list instead,
// the current one included.
//
//nolint:paralleltest // changes the process-global working directory
func TestFreshenResolvesEntriesAgainstManifestDirectory(t *testing.T) {
for _, tc := range []struct {
args []string
want []string // the paths the manifest lists afterwards
}{
{[]string{testSubdir}, []string{"b.txt", "c.txt"}},
{[]string{testSubdirManifest}, []string{"b.txt", "c.txt"}},
{
[]string{testFlagBase, ".", testSubdirManifest},
[]string{testFileTxt, "sub/b.txt", "sub/c.txt"},
},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
fs := afero.NewOsFs()
root := setupManifestInSubdir(t)
writeTestFile(t, fs, filepath.Join(root, testSubdir, "c.txt"), "added")
opts := testOpts(slices.Concat(
[]string{testApp, cmdFreshen, "-q"}, tc.args,
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, tc.want, manifestPaths(t, fs,
filepath.Join(root, testSubdirManifest)))
})
}
}
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
// with respect to MFFilePath.Mtime, which is a message pointer with // with respect to MFFilePath.Mtime, which is a message pointer with
// proto3 field presence and may legitimately be absent. // proto3 field presence and may legitimately be absent.
+37 -92
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -27,8 +26,6 @@ var (
// rendered message stays exactly as mfer has always printed it. // rendered message stays exactly as mfer has always printed it.
errOutputExists = errors.New( errOutputExists = errors.New(
"already exists (use --force to overwrite)") "already exists (use --force to overwrite)")
// errEmptyOutput indicates --output given with an empty value.
errEmptyOutput = errors.New("--output must not be empty")
) )
// reportEnumProgress renders enumeration progress until the channel // reportEnumProgress renders enumeration progress until the channel
@@ -77,7 +74,7 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
ap, err := filepath.Abs(inputPath) ap, err := filepath.Abs(inputPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid path %q: %w", inputPath, err) return nil, fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
} }
// Validate path exists before adding to list // Validate path exists before adding to list
if exists, _ := afero.Exists(mfa.Fs, ap); !exists { if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
@@ -91,40 +88,9 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
return paths, nil return paths, nil
} }
// outputPath returns the file gen writes the manifest to: the one --output // buildScannerOptions constructs scanner options from the CLI flags.
// names, or else index.mf in the directory the only argument names, or func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
// beside the file it names, or else in the current directory. An --output output := cmd.String("output")
// given with an empty value is refused.
func (mfa *CLIApp) outputPath(cmd *cli.Command) (string, error) {
if cmd.IsSet("output") {
output := cmd.String("output")
if output == "" {
return "", errEmptyOutput
}
return output, nil
}
if cmd.Args().Len() != 1 {
return defaultManifestName, nil
}
arg := cmd.Args().First()
// A path that does not exist is refused when it is enumerated.
info, err := mfa.Fs.Stat(arg)
if err == nil && !info.IsDir() {
return filepath.Join(filepath.Dir(arg), defaultManifestName), nil
}
return filepath.Join(arg, defaultManifestName), nil
}
// buildScannerOptions constructs scanner options from the CLI flags and
// the path the manifest is written to.
func (mfa *CLIApp) buildScannerOptions(
cmd *cli.Command, output string,
) (*mfer.ScannerOptions, error) {
opts := &mfer.ScannerOptions{ opts := &mfer.ScannerOptions{
IncludeDotfiles: cmd.Bool("include-dotfiles"), IncludeDotfiles: cmd.Bool("include-dotfiles"),
FollowSymLinks: cmd.Bool("follow-symlinks"), FollowSymLinks: cmd.Bool("follow-symlinks"),
@@ -145,15 +111,13 @@ func (mfa *CLIApp) buildScannerOptions(
// Set up signing options if sign-key is provided // Set up signing options if sign-key is provided
if signKey := cmd.String("sign-key"); signKey != "" { if signKey := cmd.String("sign-key"); signKey != "" {
signing, err := mfa.signingOptions(signKey) opts.SigningOptions = &mfer.SigningOptions{
if err != nil { KeyID: mfer.GPGKeyID(signKey),
return nil, err
} }
log.Infof("signing manifest with GPG key: %s", signKey)
opts.SigningOptions = signing
} }
return opts, nil return opts
} }
// enumerateInputs runs the enumeration phase over the argument paths, // enumerateInputs runs the enumeration phase over the argument paths,
@@ -165,7 +129,8 @@ func (mfa *CLIApp) enumerateInputs(
// Default to current directory // Default to current directory
err := s.EnumeratePath(".", enumProgress) err := s.EnumeratePath(".", enumProgress)
if err != nil { if err != nil {
return fmt.Errorf("enumerate current directory: %w", err) return fmt.Errorf(
"generate: failed to enumerate current directory: %w", err)
} }
return nil return nil
@@ -179,7 +144,7 @@ func (mfa *CLIApp) enumerateInputs(
err = s.EnumeratePaths(enumProgress, paths...) err = s.EnumeratePaths(enumProgress, paths...)
if err != nil { if err != nil {
return fmt.Errorf("enumerate files: %w", err) return fmt.Errorf("generate: failed to enumerate paths: %w", err)
} }
return nil return nil
@@ -238,59 +203,23 @@ func (mfa *CLIApp) runEnumeratePhase(cmd *cli.Command, s *mfer.Scanner) error {
return nil return nil
} }
// runScanPhase reads the enumerated files and writes the manifest to out,
// with optional progress reporting.
func (mfa *CLIApp) runScanPhase(
ctx context.Context, cmd *cli.Command, s *mfer.Scanner, out io.Writer,
) error {
var (
scanProgress chan mfer.ScanStatus
scanWg sync.WaitGroup
)
if cmd.Bool("progress") {
scanProgress = make(chan mfer.ScanStatus, 1)
scanWg.Add(1)
go reportScanProgress(scanProgress, &scanWg)
}
err := s.ToManifest(ctx, out, scanProgress)
scanWg.Wait()
if err != nil {
return fmt.Errorf("generate manifest: %w", err)
}
return nil
}
func (mfa *CLIApp) generateManifestOperation( func (mfa *CLIApp) generateManifestOperation(
ctx context.Context, cmd *cli.Command, ctx context.Context, cmd *cli.Command,
) error { ) error {
log.Debug("generateManifestOperation()") log.Debug("generateManifestOperation()")
outputPath, err := mfa.outputPath(cmd) s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
if err != nil {
return err
}
opts, err := mfa.buildScannerOptions(cmd, outputPath)
if err != nil {
return err
}
s := mfer.NewScannerWithOptions(opts)
// Phase 1: Enumeration - collect paths and stat files // Phase 1: Enumeration - collect paths and stat files
err = mfa.runEnumeratePhase(cmd, s) err := mfa.runEnumeratePhase(cmd, s)
if err != nil { if err != nil {
return err return err
} }
showProgress := cmd.Bool("progress")
// Check if output file exists // Check if output file exists
outputPath := cmd.String("output")
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") { if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
return fmt.Errorf("output file %s %w", outputPath, errOutputExists) return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
} }
@@ -300,7 +229,7 @@ func (mfa *CLIApp) generateManifestOperation(
outFile, err := mfa.Fs.Create(tmpPath) outFile, err := mfa.Fs.Create(tmpPath)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create temp file: %w", err)
} }
// Set up signal handler to clean up temp file on Ctrl-C // Set up signal handler to clean up temp file on Ctrl-C
@@ -321,21 +250,37 @@ func (mfa *CLIApp) generateManifestOperation(
}() }()
// Phase 2: Scan - read file contents and generate manifest // Phase 2: Scan - read file contents and generate manifest
err = mfa.runScanPhase(ctx, cmd, s, outFile) var (
scanProgress chan mfer.ScanStatus
scanWg sync.WaitGroup
)
if showProgress {
scanProgress = make(chan mfer.ScanStatus, 1)
scanWg.Add(1)
go reportScanProgress(scanProgress, &scanWg)
}
err = s.ToManifest(ctx, outFile, scanProgress)
scanWg.Wait()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to generate manifest: %w", err)
} }
// Close file before rename to ensure all data is flushed // Close file before rename to ensure all data is flushed
err = outFile.Close() err = outFile.Close()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to close temp file: %w", err)
} }
// Atomic rename // Atomic rename
err = mfa.Fs.Rename(tmpPath, outputPath) err = mfa.Fs.Rename(tmpPath, outputPath)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to rename temp file: %w", err)
} }
success = true success = true
+4 -3
View File
@@ -19,19 +19,20 @@ func (mfa *CLIApp) listManifestOperation(ctx context.Context, cmd *cli.Command)
pathOrURL, err := mfa.resolveManifestArg(cmd) pathOrURL, err := mfa.resolveManifestArg(cmd)
if err != nil { if err != nil {
return err return fmt.Errorf("list: %w", err)
} }
rc, err := mfa.openManifestReader(ctx, pathOrURL) rc, err := mfa.openManifestReader(ctx, pathOrURL)
if err != nil { if err != nil {
return err return fmt.Errorf("list: %w", err)
} }
defer func() { _ = rc.Close() }() defer func() { _ = rc.Close() }()
//nolint:contextcheck // mfer loads a manifest without a context
manifest, err := mfer.NewManifestFromReader(rc) manifest, err := mfer.NewManifestFromReader(rc)
if err != nil { if err != nil {
return fmt.Errorf("parse manifest: %w", err) return fmt.Errorf("list: failed to parse manifest: %w", err)
} }
files := manifest.Files() files := manifest.Files()
+10 -27
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -16,13 +15,13 @@ import (
// manifestFetchTimeout bounds HTTP requests made to fetch a manifest. // manifestFetchTimeout bounds HTTP requests made to fetch a manifest.
const manifestFetchTimeout = 30 * time.Second const manifestFetchTimeout = 30 * time.Second
// errHTTPStatus indicates an HTTP response with a non-OK status code. It is // errHTTPStatus indicates an HTTP response with a non-OK status code.
// followed by the code, as in "unexpected HTTP status 404". //
var errHTTPStatus = errors.New("unexpected HTTP status") // Its text is the literal "HTTP" prefix of the rendered "HTTP <code>"
// message that mfer has always printed, so that wrapping it does not
// errManifestTooLarge indicates a manifest download that passed // change any user-visible output. Match it with errors.Is; do not read
// CLIApp.maxManifestSize. // its message.
var errManifestTooLarge = errors.New("file exceeds maximum allowed size") var errHTTPStatus = errors.New("HTTP")
// isHTTPURL returns true if the string starts with http:// or https://. // isHTTPURL returns true if the string starts with http:// or https://.
func isHTTPURL(s string) bool { func isHTTPURL(s string) bool {
@@ -37,22 +36,20 @@ func (mfa *CLIApp) openManifestReader(
if isHTTPURL(pathOrURL) { if isHTTPURL(pathOrURL) {
client := &http.Client{Timeout: manifestFetchTimeout} client := &http.Client{Timeout: manifestFetchTimeout}
// The *url.Error that NewRequestWithContext and Do return names
// the URL.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pathOrURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, pathOrURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("download manifest: %w", err) return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("download manifest: %w", err) return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close() _ = resp.Body.Close()
return nil, fmt.Errorf("download manifest %s: %w %d", return nil, fmt.Errorf("failed to fetch %s: %w %d",
pathOrURL, errHTTPStatus, resp.StatusCode) pathOrURL, errHTTPStatus, resp.StatusCode)
} }
@@ -87,17 +84,3 @@ func (mfa *CLIApp) resolveManifestArg(cmd *cli.Command) (string, error) {
return findManifest(mfa.Fs, ".") return findManifest(mfa.Fs, ".")
} }
// resolveBasePath returns the directory a manifest's paths are resolved
// against: the one --base names, or else the directory holding the manifest,
// or the current directory for a manifest URL.
func resolveBasePath(cmd *cli.Command, manifestPath string) string {
switch {
case cmd.IsSet(flagBase):
return cmd.String(flagBase)
case isHTTPURL(manifestPath):
return "."
default:
return filepath.Dir(manifestPath)
}
}
+11 -18
View File
@@ -24,7 +24,6 @@ const (
cmdList = "list" cmdList = "list"
cmdVersion = "version" cmdVersion = "version"
flagBase = "base"
flagProgress = "progress" flagProgress = "progress"
flagTimeout = "timeout" flagTimeout = "timeout"
flagDest = "dest" flagDest = "dest"
@@ -60,10 +59,6 @@ type CLIApp struct {
exitCode int exitCode int
app *cli.Command app *cli.Command
// maxManifestSize is the most of a manifest that fetch, and check
// given a URL, download: mfer.MaxManifestSize, which tests lower.
maxManifestSize int64
Stdin io.Reader // Standard input stream Stdin io.Reader // Standard input stream
Stdout io.Writer // Standard output stream for normal output Stdout io.Writer // Standard output stream for normal output
Stderr io.Writer // Standard error stream for diagnostics Stderr io.Writer // Standard error stream for diagnostics
@@ -169,7 +164,7 @@ func requireSignatureFlag() *cli.StringFlag {
return &cli.StringFlag{ return &cli.StringFlag{
Name: flagRequireSignature, Name: flagRequireSignature,
Aliases: []string{"S"}, Aliases: []string{"S"},
Usage: "Require manifest to be signed by the OpenPGP key with this fingerprint", Usage: "Require manifest to be signed by the specified GPG key ID",
Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"), Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"),
} }
} }
@@ -211,10 +206,9 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "output", Name: "output",
Value: defaultManifestName,
Aliases: []string{"o"}, Aliases: []string{"o"},
Usage: "File to write the manifest to (default: index.mf in " + Usage: "Specify output filename",
"the directory given, or beside the file given; with no " +
"path or several, index.mf in the current directory)",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "force", Name: "force",
@@ -229,7 +223,7 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
&cli.StringFlag{ &cli.StringFlag{
Name: "sign-key", Name: "sign-key",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "OpenPGP secret key file to sign the manifest with", Usage: "GPG key ID to sign the manifest with",
Sources: cli.EnvVars("MFER_SIGN_KEY"), Sources: cli.EnvVars("MFER_SIGN_KEY"),
}, },
&cli.StringFlag{ &cli.StringFlag{
@@ -261,11 +255,10 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
}, },
Flags: append(commonFlags(), Flags: append(commonFlags(),
&cli.StringFlag{ &cli.StringFlag{
Name: flagBase, Name: "base",
Aliases: []string{"b"}, Aliases: []string{"b"},
Usage: "Base directory for resolving relative paths from manifest " + Value: ".",
"(by default the directory holding the manifest, or the " + Usage: "Base directory for resolving relative paths from manifest",
"current directory for a manifest URL)",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: flagProgress, Name: flagProgress,
@@ -295,10 +288,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
}, },
Flags: append(commonFlags(), Flags: append(commonFlags(),
&cli.StringFlag{ &cli.StringFlag{
Name: flagBase, Name: "base",
Aliases: []string{"b"}, Aliases: []string{"b"},
Usage: "Base directory for resolving relative paths " + Value: ".",
"(by default the directory holding the manifest)", Usage: "Base directory for resolving relative paths",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "follow-symlinks", Name: "follow-symlinks",
@@ -319,7 +312,7 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
&cli.StringFlag{ &cli.StringFlag{
Name: "sign-key", Name: "sign-key",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "OpenPGP secret key file to sign the manifest with", Usage: "GPG key ID to sign the manifest with",
Sources: cli.EnvVars("MFER_SIGN_KEY"), Sources: cli.EnvVars("MFER_SIGN_KEY"),
}, },
&cli.BoolFlag{ &cli.BoolFlag{
-79
View File
@@ -1,79 +0,0 @@
package cli
import (
"errors"
"fmt"
"os"
"github.com/spf13/afero"
"golang.org/x/term"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
)
// envSignKeyPassphrase names the environment variable holding the
// passphrase of a protected signing key.
//
//nolint:gosec // G101: the name of a variable, not a credential
const envSignKeyPassphrase = "MFER_SIGN_KEY_PASSPHRASE"
// errNoPassphrase indicates a protected signing key whose passphrase is
// neither in the environment nor can be asked for on a terminal.
var errNoPassphrase = errors.New(
"signing key is protected: set " + envSignKeyPassphrase + " to its passphrase")
// signingOptions returns the signing options for the OpenPGP secret key in
// the file path. The passphrase of a protected key comes from
// MFER_SIGN_KEY_PASSPHRASE, or else from the terminal on stdin.
func (mfa *CLIApp) signingOptions(path string) (*mfer.SigningOptions, error) {
secretKey, err := afero.ReadFile(mfa.Fs, path)
if err != nil {
return nil, fmt.Errorf("read signing key: %w", err)
}
protected, err := mfer.SecretKeyIsProtected(secretKey)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
log.Infof("signing manifest with the OpenPGP key in %s", path)
opts := &mfer.SigningOptions{SecretKey: secretKey}
if !protected {
return opts, nil
}
opts.Passphrase, err = mfa.readPassphrase(path)
if err != nil {
return nil, err
}
return opts, nil
}
// readPassphrase returns MFER_SIGN_KEY_PASSPHRASE when it is set, or else
// asks for the passphrase of the key in the file path on the terminal on
// stdin.
func (mfa *CLIApp) readPassphrase(path string) ([]byte, error) {
passphrase := os.Getenv(envSignKeyPassphrase)
if passphrase != "" {
return []byte(passphrase), nil
}
stdin, ok := mfa.Stdin.(*os.File)
if !ok || !term.IsTerminal(int(stdin.Fd())) {
return nil, errNoPassphrase
}
_, _ = fmt.Fprintf(mfa.Stderr, "Passphrase for %s: ", path)
typed, err := term.ReadPassword(int(stdin.Fd()))
_, _ = fmt.Fprintln(mfa.Stderr)
if err != nil {
return nil, fmt.Errorf("read passphrase: %w", err)
}
return typed, nil
}
-91
View File
@@ -1,91 +0,0 @@
//nolint:testpackage // white-box tests exercise unexported internals
package cli
import (
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testFlagSignKey = "--sign-key"
// TestGenAndFreshenSignWithKeyFile runs gen, then freshen after a file is
// added, with --sign-key naming a key file: one key with no passphrase and
// one protected by the passphrase in MFER_SIGN_KEY_PASSPHRASE. check
// --require-signature must accept each manifest as signed by that key.
// freshen leaves its manifest out of the listing only on the real
// filesystem, so the test uses that.
func TestGenAndFreshenSignWithKeyFile(t *testing.T) {
for name, passphrase := range map[string][]byte{
"unprotected": nil,
"protected": []byte("passphrase"),
} {
t.Run(name, func(t *testing.T) {
t.Setenv(envSignKeyPassphrase, string(passphrase))
secretKey, fingerprint := testSecretKey(t, passphrase)
fs := afero.NewOsFs()
keyFile := filepath.Join(t.TempDir(), "key.asc")
root := t.TempDir()
manifestPath := filepath.Join(root, defaultManifestName)
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
"-o", manifestPath, root,
}, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
check := []string{
testApp, cmdCheck, "-q",
"--" + flagRequireSignature, fingerprint, manifestPath,
}
opts = testOpts(check, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
opts = testOpts([]string{
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
}, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
opts = testOpts(check, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
assert.Len(t, manifestFiles(t, fs, manifestPath), 2)
})
}
}
// TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key,
// with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must
// fail, naming the variable, and write no manifest.
func TestSignWithProtectedKeyNeedsPassphrase(t *testing.T) {
t.Setenv(envSignKeyPassphrase, "")
secretKey, _ := testSecretKey(t, []byte("secret"))
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "/key.asc", secretKey, 0o600))
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", testFlagSignKey, "/key.asc",
"-o", testMF, testDir,
}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
"signing key is protected: set MFER_SIGN_KEY_PASSPHRASE to its passphrase")
exists, err := afero.Exists(fs, testMF)
require.NoError(t, err)
assert.False(t, exists)
}
+20 -30
View File
@@ -38,7 +38,6 @@ var (
errNegativeSize = errors.New("size cannot be negative") errNegativeSize = errors.New("size cannot be negative")
errHashNotMultihash = errors.New("hash is not a valid multihash") errHashNotMultihash = errors.New("hash is not a valid multihash")
errHashTooShort = errors.New("hash digest is too short") errHashTooShort = errors.New("hash digest is too short")
errDuplicatePath = errors.New("duplicate path")
) )
// ValidatePath checks that a file path conforms to manifest path invariants: // ValidatePath checks that a file path conforms to manifest path invariants:
@@ -116,7 +115,6 @@ type FileHashProgress struct {
type Builder struct { type Builder struct {
mu sync.Mutex mu sync.Mutex
files []*MFFilePath files []*MFFilePath
paths map[string]bool // the path of each entry in files
createdAt time.Time createdAt time.Time
includeTimestamps bool includeTimestamps bool
signingOptions *SigningOptions signingOptions *SigningOptions
@@ -127,7 +125,6 @@ type Builder struct {
func NewBuilder() *Builder { func NewBuilder() *Builder {
return &Builder{ return &Builder{
files: make([]*MFFilePath, 0), files: make([]*MFFilePath, 0),
paths: make(map[string]bool),
createdAt: time.Now(), createdAt: time.Now(),
} }
} }
@@ -141,7 +138,6 @@ func (b *Builder) SetSeed(seed string) {
} }
// AddFile reads file content from reader, computes hashes, and adds to manifest. // AddFile reads file content from reader, computes hashes, and adds to manifest.
// A path already added is refused once the file is read.
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none. // Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
// Progress updates are sent to the progress channel (if non-nil) without blocking. // Progress updates are sent to the progress channel (if non-nil) without blocking.
// Returns the number of bytes read. // Returns the number of bytes read.
@@ -208,7 +204,11 @@ func (b *Builder) AddFile(
Mode: uint32(mode.Perm()), Mode: uint32(mode.Perm()),
} }
return totalRead, b.addEntry(entry) b.mu.Lock()
b.files = append(b.files, entry)
b.mu.Unlock()
return totalRead, nil
} }
// sendFileHashProgress sends a progress update without blocking. // sendFileHashProgress sends a progress update without blocking.
@@ -234,9 +234,8 @@ func (b *Builder) FileCount() int {
// AddFileWithHash adds a file entry with a pre-computed hash. // AddFileWithHash adds a file entry with a pre-computed hash.
// This is useful when the hash is already known (e.g., from an existing manifest). // 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. // 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, // Returns an error if path is invalid, size is negative, or hash is not a
// or hash is not a multihash with a digest of at least 32 bytes, as long // multihash with a digest of at least 32 bytes, as long as SHA-256's.
// as SHA-256's.
func (b *Builder) AddFileWithHash( func (b *Builder) AddFileWithHash(
path RelFilePath, path RelFilePath,
size FileSize, size FileSize,
@@ -246,7 +245,7 @@ func (b *Builder) AddFileWithHash(
) error { ) error {
err := ValidatePath(string(path)) err := ValidatePath(string(path))
if err != nil { if err != nil {
return err return fmt.Errorf("add file: %w", err)
} }
if size < 0 { if size < 0 {
@@ -278,7 +277,11 @@ func (b *Builder) AddFileWithHash(
Mode: uint32(mode.Perm()), Mode: uint32(mode.Perm()),
} }
return b.addEntry(entry) b.mu.Lock()
b.files = append(b.files, entry)
b.mu.Unlock()
return nil
} }
// SetIncludeTimestamps controls whether the manifest includes a createdAt timestamp. // SetIncludeTimestamps controls whether the manifest includes a createdAt timestamp.
@@ -290,7 +293,7 @@ func (b *Builder) SetIncludeTimestamps(include bool) {
b.includeTimestamps = include b.includeTimestamps = include
} }
// SetSigningOptions sets the key the manifest is signed with. // SetSigningOptions sets the GPG signing options for the manifest.
// If opts is non-nil, the manifest will be signed when Build() is called. // If opts is non-nil, the manifest will be signed when Build() is called.
func (b *Builder) SetSigningOptions(opts *SigningOptions) { func (b *Builder) SetSigningOptions(opts *SigningOptions) {
b.mu.Lock() b.mu.Lock()
@@ -299,8 +302,8 @@ func (b *Builder) SetSigningOptions(opts *SigningOptions) {
b.signingOptions = opts b.signingOptions = opts
} }
// Build finalizes the manifest and writes it to the writer. When signing // Build finalizes the manifest and writes it to the writer. ctx bounds the
// options are set, it does not sign once ctx has ended. // gpg runs that sign the manifest when signing options are set.
func (b *Builder) Build(ctx context.Context, w io.Writer) error { func (b *Builder) Build(ctx context.Context, w io.Writer) error {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
@@ -329,33 +332,20 @@ func (b *Builder) Build(ctx context.Context, w io.Writer) error {
// Generate outer wrapper // Generate outer wrapper
err := m.generateOuter(ctx) err := m.generateOuter(ctx)
if err != nil { if err != nil {
return err return fmt.Errorf("build: generate outer: %w", err)
} }
// Generate final output // Generate final output
err = m.generate(ctx) err = m.generate(ctx)
if err != nil { if err != nil {
return err return fmt.Errorf("build: generate: %w", err)
} }
// Write to output // Write to output
_, err = w.Write(m.output.Bytes()) _, err = w.Write(m.output.Bytes())
if err != nil {
return err return fmt.Errorf("build: write output: %w", err)
}
// addEntry adds entry to the manifest unless an entry with its path is
// already there.
func (b *Builder) addEntry(entry *MFFilePath) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.paths[entry.GetPath()] {
return fmt.Errorf("%w %q", errDuplicatePath, entry.GetPath())
} }
b.paths[entry.GetPath()] = true
b.files = append(b.files, entry)
return nil return nil
} }
-26
View File
@@ -125,32 +125,6 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
} }
} }
// TestBuilderRefusesPathAlreadyAdded adds a path, then adds it again with
// AddFile and with AddFileWithHash. Each must refuse it, naming it, and
// keep the one entry already added.
func TestBuilderRefusesPathAlreadyAdded(t *testing.T) {
t.Parallel()
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
require.NoError(t, err)
b := NewBuilder()
require.NoError(t, b.AddFileWithHash("dir/a.txt", 4, ModTime{}, 0, hash))
content := []byte("data")
_, err = b.AddFile(
"dir/a.txt", FileSize(len(content)), ModTime{}, 0, bytes.NewReader(content), nil,
)
require.ErrorIs(t, err, errDuplicatePath)
require.EqualError(t, err, `duplicate path "dir/a.txt"`)
err = b.AddFileWithHash("dir/a.txt", 4, ModTime{}, 0, hash)
require.ErrorIs(t, err, errDuplicatePath)
require.EqualError(t, err, `duplicate path "dir/a.txt"`)
assert.Equal(t, 1, b.FileCount())
}
func TestBuilderBuild(t *testing.T) { func TestBuilderBuild(t *testing.T) {
t.Parallel() t.Parallel()
+1 -8
View File
@@ -8,17 +8,10 @@ const (
ReleaseDate = "2025-12-17" ReleaseDate = "2025-12-17"
// MaxDecompressedSize is the maximum allowed size of decompressed manifest // MaxDecompressedSize is the maximum allowed size of decompressed manifest
// data (256 MiB). This prevents decompression bombs from consuming excessive // data (256 MB). This prevents decompression bombs from consuming excessive
// memory. // memory.
MaxDecompressedSize int64 = 256 * 1024 * 1024 MaxDecompressedSize int64 = 256 * 1024 * 1024
// MaxManifestSize is the largest manifest file mfer reads (258 MiB).
// zstd's worst case grows data it cannot compress by 1/256, so an inner
// message of MaxDecompressedSize compresses to at most 257 MiB; the
// last MiB is room for the signature, the signing key and the other
// outer fields.
MaxManifestSize = MaxDecompressedSize + MaxDecompressedSize/256 + 1<<20
// zstdWindowSize is the zstd window zstd.SpeedBestCompression gives mfer's writer. // zstdWindowSize is the zstd window zstd.SpeedBestCompression gives mfer's writer.
zstdWindowSize = 8 << 20 zstdWindowSize = 8 << 20
+22 -46
View File
@@ -2,6 +2,7 @@ package mfer
import ( import (
"bytes" "bytes"
"context"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
"fmt" "fmt"
@@ -23,12 +24,12 @@ var (
errCompressedHashWrong = errors.New("compressed data hash mismatch") errCompressedHashWrong = errors.New("compressed data hash mismatch")
errSignatureNoPubKey = errors.New("signature present but no public key") errSignatureNoPubKey = errors.New("signature present but no public key")
errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size") errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size")
errManifestTooLarge = errors.New("file exceeds maximum allowed size")
errUUIDMismatch = errors.New("outer and inner UUID mismatch") errUUIDMismatch = errors.New("outer and inner UUID mismatch")
errInvalidFileFormat = errors.New("invalid file format") errInvalidFileFormat = errors.New("invalid file format")
errInvalidManifestPath = errors.New("invalid file entry") errInvalidManifestPath = errors.New("manifest contains invalid path")
errDecodedTooLarge = errors.New("too much memory needed") errDecodedTooLarge = errors.New(
errSignerNotSigningKey = errors.New( "manifest would take too much memory to decode")
errSignerNotSigningKey = errors.New(
"signer is not the fingerprint of the key that made the signature") "signer is not the fingerprint of the key that made the signature")
) )
@@ -56,7 +57,7 @@ func (m *manifest) validateOuterHeader() error {
// Validate outer UUID before any decompression // Validate outer UUID before any decompression
err := validateUUID(m.pbOuter.GetUuid()) err := validateUUID(m.pbOuter.GetUuid())
if err != nil { if err != nil {
return fmt.Errorf("outer message: %w", err) return fmt.Errorf("outer UUID invalid: %w", err)
} }
return nil return nil
@@ -71,7 +72,7 @@ func (m *manifest) verifyOuterIntegrity() error {
_, err := h.Write(m.pbOuter.GetInnerMessage()) _, err := h.Write(m.pbOuter.GetInnerMessage())
if err != nil { if err != nil {
return fmt.Errorf("hash inner message: %w", err) return fmt.Errorf("deserialize: hash write: %w", err)
} }
sha256Hash := h.Sum(nil) sha256Hash := h.Sum(nil)
@@ -89,16 +90,20 @@ func (m *manifest) verifyOuterIntegrity() error {
sigString, err := m.signatureString() sigString, err := m.signatureString()
if err != nil { if err != nil {
return fmt.Errorf("build signature string: %w", err) return fmt.Errorf(
"failed to generate signature string for verification: %w", err,
)
} }
signingKey, err := verifySignature( // Loading a manifest takes no context; gpgTimeout still bounds gpg.
signingKey, err := gpgVerify(
context.Background(),
[]byte(sigString), []byte(sigString),
m.pbOuter.GetSignature(), m.pbOuter.GetSignature(),
m.pbOuter.GetSigningPubKey(), m.pbOuter.GetSigningPubKey(),
) )
if err != nil { if err != nil {
return err return fmt.Errorf("signature verification failed: %w", err)
} }
if !strings.EqualFold(string(m.pbOuter.GetSigner()), signingKey) { if !strings.EqualFold(string(m.pbOuter.GetSigner()), signingKey) {
@@ -129,7 +134,7 @@ func (m *manifest) decompressInner() ([]byte, error) {
zstd.WithDecodeBuffersBelow(0), zstd.WithDecodeBuffersBelow(0),
zstd.WithDecoderMaxWindow(zstdWindowSize)) zstd.WithDecoderMaxWindow(zstdWindowSize))
if err != nil { if err != nil {
return nil, fmt.Errorf("create decompressor: %w", err) return nil, fmt.Errorf("deserialize: zstd reader: %w", err)
} }
defer zr.Close() defer zr.Close()
@@ -144,7 +149,7 @@ func (m *manifest) decompressInner() ([]byte, error) {
dat, err := io.ReadAll(limitedReader) dat, err := io.ReadAll(limitedReader)
if err != nil { if err != nil {
return nil, fmt.Errorf("decompress inner message: %w", err) return nil, fmt.Errorf("deserialize: decompress: %w", err)
} }
if int64(len(dat)) >= MaxDecompressedSize { if int64(len(dat)) >= MaxDecompressedSize {
@@ -260,7 +265,7 @@ func (m *manifest) deserializeInner() error {
err = checkDecodedSize(dat) err = checkDecodedSize(dat)
if err != nil { if err != nil {
return fmt.Errorf("unmarshal inner message: %w", err) return fmt.Errorf("deserialize: unmarshal inner: %w", err)
} }
// Deserialize inner message // Deserialize inner message
@@ -269,17 +274,13 @@ func (m *manifest) deserializeInner() error {
// Unknown fields would cost memory; mfer never writes a loaded manifest out. // Unknown fields would cost memory; mfer never writes a loaded manifest out.
err = proto.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(dat, m.pbInner) err = proto.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(dat, m.pbInner)
if err != nil { if err != nil {
return fmt.Errorf("unmarshal inner message: %w", err) return fmt.Errorf("deserialize: unmarshal inner: %w", err)
}
if m.pbInner.GetVersion() != MFFile_VERSION_ONE {
return errUnknownVersion
} }
// Validate inner UUID // Validate inner UUID
err = validateUUID(m.pbInner.GetUuid()) err = validateUUID(m.pbInner.GetUuid())
if err != nil { if err != nil {
return fmt.Errorf("inner message: %w", err) return fmt.Errorf("inner UUID invalid: %w", err)
} }
// Verify UUIDs match // Verify UUIDs match
@@ -292,21 +293,12 @@ func (m *manifest) deserializeInner() error {
// extract path tomorrow — acts on a traversal or absolute path from an // extract path tomorrow — acts on a traversal or absolute path from an
// untrusted .mf. Reject loudly on the first offender rather than // untrusted .mf. Reject loudly on the first offender rather than
// dropping entries, which would let a hostile manifest hide files from a // 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 // check.
// file against both entries.
seen := make(map[string]bool, len(m.pbInner.GetFiles()))
for _, f := range m.pbInner.GetFiles() { for _, f := range m.pbInner.GetFiles() {
err = ValidatePath(f.GetPath()) err = ValidatePath(f.GetPath())
if err != nil { if err != nil {
return fmt.Errorf("%w: %w", errInvalidManifestPath, err) return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
} }
if seen[f.GetPath()] {
return fmt.Errorf("%w %q", errDuplicatePath, f.GetPath())
}
seen[f.GetPath()] = true
} }
log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles())) log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles()))
@@ -326,14 +318,13 @@ func validateMagic(dat []byte) bool {
return bytes.Equal(got, expected) return bytes.Equal(got, expected)
} }
// NewManifestFromReader reads a manifest from an io.Reader. It refuses a // NewManifestFromReader reads a manifest from an io.Reader.
// manifest larger than MaxManifestSize, reading at most one byte past it.
// //
//nolint:revive // unexported-return: exporting manifest is owner question 13 //nolint:revive // unexported-return: exporting manifest is owner question 13
func NewManifestFromReader(input io.Reader) (*manifest, error) { func NewManifestFromReader(input io.Reader) (*manifest, error) {
m := &manifest{} m := &manifest{}
dat, err := readAtMost(input, MaxManifestSize) dat, err := io.ReadAll(input)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -365,21 +356,6 @@ func NewManifestFromReader(input io.Reader) (*manifest, error) {
return m, nil return m, nil
} }
// readAtMost reads all of input, or refuses it with errManifestTooLarge
// once it passes maxSize bytes, after reading one byte past maxSize.
func readAtMost(input io.Reader, maxSize int64) ([]byte, error) {
dat, err := io.ReadAll(io.LimitReader(input, maxSize+1))
if err != nil {
return nil, err
}
if int64(len(dat)) > maxSize {
return nil, fmt.Errorf("%w of %d bytes", errManifestTooLarge, maxSize)
}
return dat, nil
}
// ManifestFromFileOptions configures NewManifestFromFile. // ManifestFromFileOptions configures NewManifestFromFile.
type ManifestFromFileOptions struct { type ManifestFromFileOptions struct {
// Path is the manifest file to read (required). // Path is the manifest file to read (required).
+8
View File
@@ -19,6 +19,14 @@ import (
// input and of the decompressed data it may read, plus room for the // input and of the decompressed data it may read, plus room for the
// decoder's window buffers. A panic or a hang fails the test on its own. // decoder's window buffers. A panic or a hang fails the test on its own.
func FuzzNewManifestFromReader(f *testing.F) { func FuzzNewManifestFromReader(f *testing.F) {
// A signed manifest makes the parser write the key and signature to a
// temporary directory and run gpg on them. With gpg off the PATH and
// temporary files kept in the test's own directory, no process is
// started and nothing is written elsewhere; such input ends in an
// error instead.
f.Setenv("PATH", "")
f.Setenv("TMPDIR", f.TempDir())
f.Fuzz(func(t *testing.T, data []byte) { f.Fuzz(func(t *testing.T, data []byte) {
var before, after runtime.MemStats var before, after runtime.MemStats
+16 -68
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
"uuid" "uuid"
@@ -117,61 +118,12 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
} }
} }
// A manifest that lists a path twice is refused as it is loaded, naming the
// path. Paths are compared byte for byte: two that differ only in letter case
// load, and fetch refuses those itself. Each entry has a hash, as entries mfer
// writes do; entries of a path alone would take too much memory to decode.
func TestDeserializeRefusesPathListedTwice(t *testing.T) {
t.Parallel()
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
require.NoError(t, err)
tests := []struct {
name string
paths []string
refused bool
}{
{"same path twice", []string{"dir/a.txt", "other.txt", "dir/a.txt"}, true},
{"paths differing in letter case", []string{"dir/b.txt", "dir/B.txt"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
id := uuid.NewV4()
inner := &MFFile{Version: MFFile_VERSION_ONE, Uuid: id[:]}
for _, p := range tt.paths {
inner.Files = append(inner.Files, &MFFilePath{
Path: p,
Hashes: []*MFFileChecksum{{MultiHash: hash}},
})
}
innerData, err := proto.Marshal(inner)
require.NoError(t, err)
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, innerData)))
if tt.refused {
require.ErrorIs(t, err, errDuplicatePath)
require.EqualError(t, err, `duplicate path "dir/a.txt"`)
} else {
require.NoError(t, err)
assert.Len(t, m.Files(), len(tt.paths))
}
})
}
}
// Entries of a path, an empty hash, an empty MIME type and empty modification // 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 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 // 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 // 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 // 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, // it 55 bytes, about 7.9 times: loaded.
// padded with zeros to that length, since a manifest lists a path only once.
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) { func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
t.Parallel() t.Parallel()
@@ -187,24 +139,22 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
t.Run(strconv.Itoa(tt.pathLen), func(t *testing.T) { t.Run(strconv.Itoa(tt.pathLen), func(t *testing.T) {
t.Parallel() t.Parallel()
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
entry = protowire.AppendString(entry, strings.Repeat("a", tt.pathLen))
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
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
entry = protowire.AppendBytes(entry, nil)
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // MFFilePath.ctime
entry = protowire.AppendBytes(entry, nil)
id := uuid.NewV4() id := uuid.NewV4()
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version inner := protowire.AppendTag(nil, 102, protowire.BytesType) // MFFile.uuid
inner = protowire.AppendVarint(inner, uint64(MFFile_VERSION_ONE))
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
inner = protowire.AppendBytes(inner, id[:]) inner = protowire.AppendBytes(inner, id[:])
for i := range 1000 { for range 1000 {
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.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, nil)
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
entry = protowire.AppendBytes(entry, nil)
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
entry = protowire.AppendBytes(entry, nil)
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // MFFilePath.ctime
entry = protowire.AppendBytes(entry, nil)
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
inner = protowire.AppendBytes(inner, entry) inner = protowire.AppendBytes(inner, entry)
} }
@@ -232,9 +182,7 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
entry = append(entry, unknown...) entry = append(entry, unknown...)
id := uuid.NewV4() id := uuid.NewV4()
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version inner := protowire.AppendTag(nil, 101, protowire.BytesType) // MFFile.files
inner = protowire.AppendVarint(inner, uint64(MFFile_VERSION_ONE))
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
inner = protowire.AppendBytes(inner, entry) inner = protowire.AppendBytes(inner, entry)
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
inner = protowire.AppendBytes(inner, id[:]) inner = protowire.AppendBytes(inner, id[:])
-54
View File
@@ -1,54 +0,0 @@
//nolint:testpackage // white-box tests exercise unexported internals
package mfer
import (
"bytes"
"testing"
"uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
// An inner message whose version is not VERSION_ONE, whether version 0 or a
// later one, is refused with the same error as an outer message's.
func TestDeserializeRefusesUnknownInnerVersion(t *testing.T) {
t.Parallel()
for _, version := range []MFFile_Version{MFFile_VERSION_NONE, MFFile_VERSION_ONE + 1} {
t.Run(version.String(), func(t *testing.T) {
t.Parallel()
id := uuid.NewV4()
inner, err := proto.Marshal(&MFFile{Version: version, Uuid: id[:]})
require.NoError(t, err)
_, err = NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
require.ErrorIs(t, err, errUnknownVersion)
})
}
}
// TestReadAtMost gives readAtMost exactly its maximum, which it must
// return whole, and twice its maximum, which it must refuse after reading
// one byte past the maximum, and no more. NewManifestFromReader reads
// through it with MaxManifestSize; the test uses 64 KiB, since reading
// MaxManifestSize under the race detector takes gigabytes of memory.
func TestReadAtMost(t *testing.T) {
t.Parallel()
const maxSize = 64 << 10
dat, err := readAtMost(bytes.NewReader(make([]byte, maxSize)), maxSize)
require.NoError(t, err)
assert.Len(t, dat, maxSize)
input := bytes.NewReader(make([]byte, 2*maxSize))
_, err = readAtMost(input, maxSize)
require.ErrorIs(t, err, errManifestTooLarge)
require.EqualError(t, err,
"file exceeds maximum allowed size of 65536 bytes")
assert.Equal(t, maxSize-1, input.Len(), "bytes left unread")
}
+7 -11
View File
@@ -74,18 +74,14 @@ func TestValidatePathMessagesVerbatim(t *testing.T) {
} }
} }
// TestSerializeInnerNotSetMessagesVerbatim pins the messages generate and // TestSerializeInternalErrorMessagesVerbatim pins the two distinct
// generateOuter return when the inner message is missing. // "internal error" messages, which differ between generate and
func TestSerializeInnerNotSetMessagesVerbatim(t *testing.T) { // generateOuter and have always done so.
func TestSerializeInternalErrorMessagesVerbatim(t *testing.T) {
t.Parallel() t.Parallel()
m := &manifest{} m := &manifest{}
require.EqualError(t, m.generate(context.Background()),
err := m.generate(context.Background()) "internal error: pbInner not set")
require.ErrorIs(t, err, errInnerNotSet) require.EqualError(t, m.generateOuter(context.Background()), "internal error")
require.EqualError(t, err, "inner message not set")
err = m.generateOuter(context.Background())
require.ErrorIs(t, err, errInternal)
require.EqualError(t, err, "inner message not set")
} }
+332
View File
@@ -0,0 +1,332 @@
package mfer
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
// gpgTimeout bounds every gpg run, which can otherwise wait forever on
// a passphrase prompt or a stalled gpg-agent. A minute leaves a person
// time to type a passphrase or touch a smartcard.
gpgTimeout = time.Minute
// gpgWaitDelay is how long a gpg run keeps waiting for gpg's stdout
// and stderr to close once gpg has been killed or has exited. Reading
// what gpg itself wrote takes far less; only a process gpg left behind
// holds them open longer.
gpgWaitDelay = time.Second
// privateDirPerms is the permission mode for temporary GPG home
// directories.
privateDirPerms os.FileMode = 0o700
// privateFilePerms is the permission mode for temporary key,
// signature, and data files.
privateFilePerms os.FileMode = 0o600
// gpgFingerprintField is the record type tag for fingerprint lines
// in gpg --with-colons output.
gpgFingerprintField = "fpr"
// gpgFingerprintMinFields is the minimum number of colon-separated
// fields in a gpg fingerprint record (the fingerprint is field 10).
gpgFingerprintMinFields = 10
// gpgStatusPrefix starts each status line gpg writes to the file
// descriptor named by --status-fd.
gpgStatusPrefix = "[GNUPG:]"
// gpg option names used from more than one call site.
gpgOptArmor = "--armor"
gpgOptHomedir = "--homedir"
gpgOptStatusFD = "--status-fd"
gpgOptVerify = "--verify"
)
var (
errGPGKeyNotFound = errors.New("gpg key not found")
errFingerprintNotFound = errors.New("fingerprint not found for key")
errSigningKeyCount = errors.New(
"embedded public key block must hold exactly one key")
errNotOneGoodSignature = errors.New(
"gpg did not report exactly one good signature")
errSigningKeyNotReported = errors.New(
"gpg did not report the key that made the signature")
)
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
type GPGKeyID string
// SigningOptions contains options for GPG signing.
type SigningOptions struct {
KeyID GPGKeyID
}
// gpgArgs builds a gpg argument list from opts followed by positional
// arguments, separated by an explicit "--" end-of-options marker.
//
// This matters because key IDs reach gpg as bare positional arguments
// (from --sign-key / MFER_SIGN_KEY) and gpg would otherwise parse a value
// beginning with "-" as one of its own options. Callers must route every
// non-option argument through here.
func gpgArgs(opts []string, positional ...string) []string {
args := make([]string, 0, len(opts)+1+len(positional))
args = append(args, opts...)
args = append(args, "--")
args = append(args, positional...)
return args
}
// runGPG runs the gpg binary in batch mode with the given arguments and
// optional stdin, returning captured stdout and stderr. gpg is killed when
// ctx ends or gpgTimeout passes, whichever comes first.
func runGPG(
ctx context.Context, stdin io.Reader, args ...string,
) (*bytes.Buffer, *bytes.Buffer, error) {
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg
// starts runs detached and holds none of gpg's output, but another
// process gpg leaves behind (a wrapper script that runs the real gpg
// without exec, for example) can keep gpg's stdout or stderr open, and
// Run would wait for it to exit. WaitDelay stops that wait
// gpgWaitDelay after the kill; that process is left running.
ctx, cancel := context.WithTimeout(ctx, gpgTimeout)
defer cancel()
fullArgs := append([]string{"--batch", "--no-tty"}, args...)
// G204: the executable name is a compile-time constant. The arguments
// are not, so the guarantee that matters is placement: every
// caller-supplied value is passed either as the value of a named
// option or after the "--" end-of-options marker inserted by gpgArgs,
// and therefore cannot be reinterpreted by gpg as an option.
cmd := exec.CommandContext( //nolint:gosec // G204: see comment above
ctx, "gpg", fullArgs...)
cmd.WaitDelay = gpgWaitDelay
cmd.Stdin = stdin
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil && ctx.Err() != nil {
// gpg was killed because ctx ended, which Run reports only as
// "signal: killed"; return the reason instead.
err = ctx.Err()
if errors.Is(err, context.DeadlineExceeded) {
err = fmt.Errorf("gpg timed out: %w", err)
}
}
return &stdout, &stderr, err
}
// parseFingerprint extracts the first fingerprint from gpg --with-colons
// output, or returns ok=false if none is present.
func parseFingerprint(colonOutput string) (string, bool) {
for line := range strings.SplitSeq(colonOutput, "\n") {
fields := strings.Split(line, ":")
if len(fields) >= gpgFingerprintMinFields &&
fields[0] == gpgFingerprintField {
return fields[9], true
}
}
return "", false
}
// parseStatusLine returns the arguments of the status line for keyword in
// gpg --status-fd output, or ok=false unless there is exactly one such line
// and it has arguments.
func parseStatusLine(statusOutput, keyword string) ([]string, bool) {
var found [][]string
for line := range strings.SplitSeq(statusOutput, "\n") {
fields := strings.Fields(line)
if len(fields) > 2 && fields[0] == gpgStatusPrefix && fields[1] == keyword {
found = append(found, fields[2:])
}
}
if len(found) != 1 {
return nil, false
}
return found[0], true
}
// gpgSign creates an armored detached signature of data with the key gpg
// picks for keyID, and returns it with the fingerprint of the key that made
// it, which is a subkey's when gpg signed with a subkey.
func gpgSign(
ctx context.Context, data []byte, keyID GPGKeyID,
) ([]byte, string, error) {
// The signature goes to stdout, so the status lines go to stderr.
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
"--detach-sign",
gpgOptArmor,
gpgOptStatusFD, "2",
"--local-user", string(keyID),
)
if err != nil {
return nil, "", fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
}
// The last argument of SIG_CREATED is the fingerprint of the key that
// made the signature.
created, ok := parseStatusLine(stderr.String(), "SIG_CREATED")
if !ok {
return nil, "", fmt.Errorf("%w: %s", errSigningKeyNotReported, stderr.String())
}
return stdout.Bytes(), created[len(created)-1], nil
}
// gpgExportPublicKey exports the public key for the specified key ID.
// Returns the armored public key.
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
stdout, stderr, err := runGPG(ctx, nil,
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
)
if err != nil {
return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String())
}
if stdout.Len() == 0 {
return nil, fmt.Errorf("%w: %s", errGPGKeyNotFound, keyID)
}
return stdout.Bytes(), nil
}
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
stdout, stderr, err := runGPG(ctx, nil,
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
)
if err != nil {
return nil, fmt.Errorf(
"gpg fingerprint lookup failed: %w: %s", err, stderr.String(),
)
}
fpr, ok := parseFingerprint(stdout.String())
if !ok {
return nil, fmt.Errorf("%w: %s", errFingerprintNotFound, keyID)
}
return []byte(fpr), nil
}
// gpgImportOneKey imports the public key block in pubKeyFile into the
// keyring in gpgHome. The block must hold exactly one primary key.
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
// --status-fd 1 sends gpg's status lines to stdout, which importing
// otherwise leaves empty; its messages go to stderr.
importStdout, importStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
pubKeyFile)...,
)
if err != nil {
return fmt.Errorf(
"failed to import public key: %w: %s", err, importStderr.String(),
)
}
// The first argument of IMPORT_RES counts the primary keys gpg read
// from the block, those it then skipped (one with no user ID, for
// example) included.
result, ok := parseStatusLine(importStdout.String(), "IMPORT_RES")
if !ok {
return fmt.Errorf("%w, gpg reported no count", errSigningKeyCount)
}
if result[0] != "1" {
return fmt.Errorf("%w, found %s", errSigningKeyCount, result[0])
}
return nil
}
// gpgVerify verifies a detached signature against data using the provided
// public key, imported into a temporary keyring, and returns the
// fingerprint of the primary key that made the signature. The public key
// must hold exactly one primary key, so that a good signature can come
// from no other key.
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, error) {
// Create temporary directory for GPG operations
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Set restrictive permissions
err = os.Chmod(tmpDir, privateDirPerms)
if err != nil {
return "", fmt.Errorf("failed to set temp dir permissions: %w", err)
}
// Write public key to temp file
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
if err != nil {
return "", fmt.Errorf("failed to write public key: %w", err)
}
// Write signature to temp file
sigFile := filepath.Join(tmpDir, "signature.asc")
err = os.WriteFile(sigFile, signature, privateFilePerms)
if err != nil {
return "", fmt.Errorf("failed to write signature: %w", err)
}
// Write data to temp file
dataFile := filepath.Join(tmpDir, "data")
err = os.WriteFile(dataFile, data, privateFilePerms)
if err != nil {
return "", fmt.Errorf("failed to write data: %w", err)
}
err = gpgImportOneKey(ctx, tmpDir, pubKeyFile)
if err != nil {
return "", err
}
// --status-fd 1 sends gpg's status lines to stdout, which verifying a
// detached signature otherwise leaves empty; its messages go to stderr.
verifyStdout, verifyStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
sigFile, dataFile)...,
)
if err != nil {
return "", fmt.Errorf(
"signature verification failed: %w: %s", err, verifyStderr.String(),
)
}
// gpg writes a VALIDSIG line for each good signature. Its first
// argument is the fingerprint of the key that made the signature,
// which may be a subkey; its last is that of the primary key.
valid, ok := parseStatusLine(verifyStdout.String(), "VALIDSIG")
if !ok {
return "", errNotOneGoodSignature
}
return valid[len(valid)-1], nil
}
+674
View File
@@ -0,0 +1,674 @@
//nolint:testpackage // white-box tests exercise unexported internals
package mfer
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
// testGPGEnv sets up a temporary GPG home directory with a test key.
// Returns the key ID and the GPG home directory; callers must point
// GNUPGHOME at the returned directory (via t.Setenv) before using the
// gpg helpers under test.
func testGPGEnv(t *testing.T) (GPGKeyID, string) {
t.Helper()
// Check if gpg is installed
_, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not installed, skipping signing test")
}
// Create temporary GPG home directory (0700 by default)
gpgHome := t.TempDir()
genTestKey(t, gpgHome, testKeyParams)
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
defer cancel()
// Get the key fingerprint
cmd := exec.CommandContext(ctx, "gpg",
"--list-keys", "--with-colons", "test@mfer.test")
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to list test key: %v", err)
}
// Parse fingerprint from output
var keyID string
for line := range strings.SplitSeq(string(output), "\n") {
fields := strings.Split(line, ":")
if len(fields) >= gpgFingerprintMinFields &&
fields[0] == gpgFingerprintField {
keyID = fields[9]
break
}
}
if keyID == "" {
t.Fatal("failed to find test key fingerprint")
}
return GPGKeyID(keyID), gpgHome
}
// testKeyParams are the gpg key generation parameters of an RSA key that
// signs and does not expire.
const testKeyParams = "Key-Type: RSA\nKey-Length: 2048\nExpire-Date: 0\n"
// genTestKey generates a key with no passphrase for
// "MFER Test Key <test@mfer.test>" from the gpg key generation parameters
// keyParams in gpgHome, which may already hold one.
func genTestKey(t *testing.T, gpgHome, keyParams string) {
t.Helper()
params := "%no-protection\n" + keyParams +
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n%commit\n"
paramsFile := filepath.Join(gpgHome, "key-params")
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
defer cancel()
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
cmd := exec.CommandContext(ctx, "gpg",
"--batch", "--gen-key", paramsFile)
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
output, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
}
}
// signedTestManifest returns a manifest of one file signed with keyID.
func signedTestManifest(t *testing.T, keyID GPGKeyID) []byte {
t.Helper()
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{KeyID: keyID})
content := []byte("signed file content")
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0,
bytes.NewReader(content), nil)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, b.Build(context.Background(), &buf))
return buf.Bytes()
}
// rewriteOuter returns manifest with its outer message changed by edit.
// A signature stays good as long as edit leaves the UUID and hash alone.
func rewriteOuter(t *testing.T, manifest []byte, edit func(*MFFileOuter)) []byte {
t.Helper()
outer := new(MFFileOuter)
require.NoError(t, proto.Unmarshal(manifest[len(MAGIC):], outer))
edit(outer)
data, err := proto.Marshal(outer)
require.NoError(t, err)
return append([]byte(MAGIC), data...)
}
func TestGPGSign(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
data := []byte("test data to sign")
sig, signingKey, err := gpgSign(context.Background(), data, keyID)
require.NoError(t, err)
assert.Equal(t, string(keyID), signingKey)
assert.NotEmpty(t, sig)
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----")
}
func TestGPGExportPublicKey(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
require.NoError(t, err)
assert.NotEmpty(t, pubKey)
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
assert.Contains(t, string(pubKey), "-----END PGP PUBLIC KEY BLOCK-----")
}
func TestGPGGetKeyFingerprint(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
fingerprint, err := gpgGetKeyFingerprint(context.Background(), keyID)
require.NoError(t, err)
assert.NotEmpty(t, fingerprint)
// The fingerprint should be 40 hex chars
assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars")
}
// TestGPGArgsSeparatesPositionals pins that caller-supplied values are
// placed after an end-of-options marker. Key IDs arrive from --sign-key
// and MFER_SIGN_KEY as bare positional arguments, so without the marker
// a value beginning with "-" would be parsed by gpg as one of its own
// options.
func TestGPGArgsSeparatesPositionals(t *testing.T) {
t.Parallel()
assert.Equal(t,
[]string{"--opt-a", "--opt-b", "--", "--version"},
gpgArgs([]string{"--opt-a", "--opt-b"}, "--version"))
assert.Equal(t,
[]string{"--opt-c", "--", "sig", "data"},
gpgArgs([]string{"--opt-c"}, "sig", "data"))
assert.Equal(t, []string{"--opt-d", "--"},
gpgArgs([]string{"--opt-d"}))
}
// TestGPGOptionLikeKeyIDIsNotAnOption drives real gpg with a key ID that
// looks like an option and asserts it is treated as a (nonexistent) key
// rather than executed as gpg's own --version.
func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
_, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
pubKey, err := gpgExportPublicKey(context.Background(), GPGKeyID("--version"))
require.Error(t, err)
require.ErrorIs(t, err, errGPGKeyNotFound)
assert.NotContains(t, string(pubKey), "gpg (GnuPG)")
fpr, err := gpgGetKeyFingerprint(context.Background(), GPGKeyID("--version"))
require.Error(t, err)
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
}
func TestGPGSignInvalidKey(t *testing.T) {
// Set up test environment (we need GNUPGHOME set)
_, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
data := []byte("test data")
_, _, err := gpgSign(context.Background(), data,
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
assert.Error(t, err)
}
func TestBuilderWithSigning(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
// Create a builder with signing options
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{
KeyID: keyID,
})
// Add a test file
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
var buf bytes.Buffer
err = b.Build(context.Background(), &buf)
require.NoError(t, err)
// Parse the manifest and verify signature fields are populated
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
require.NotNil(t, manifest.pbOuter)
assert.NotEmpty(t, manifest.pbOuter.GetSignature(),
"signature should be populated")
assert.NotEmpty(t, manifest.pbOuter.GetSigner(), "signer should be populated")
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey(),
"signing public key should be populated")
// Verify signature is a valid PGP signature
assert.Contains(t, string(manifest.pbOuter.GetSignature()),
"-----BEGIN PGP SIGNATURE-----")
// Verify public key is a valid PGP public key block
assert.Contains(t, string(manifest.pbOuter.GetSigningPubKey()),
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
}
func TestScannerWithSigning(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
// Create in-memory filesystem with test files
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
require.NoError(t,
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
require.NoError(t,
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
// Create scanner with signing options
opts := &ScannerOptions{
Fs: fs,
SigningOptions: &SigningOptions{
KeyID: keyID,
},
}
s := NewScannerWithOptions(opts)
// Enumerate files
require.NoError(t, s.EnumeratePath("/testdir", nil))
assert.Equal(t, FileCount(2), s.FileCount())
// Generate signed manifest
var buf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
// Parse and verify
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
assert.NotEmpty(t, manifest.pbOuter.GetSigner())
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey())
}
func TestGPGVerify(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
data := []byte("test data to sign and verify")
sig, _, err := gpgSign(context.Background(), data, keyID)
require.NoError(t, err)
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
require.NoError(t, err)
// Verify the signature; it names the key that made it
signingKey, err := gpgVerify(context.Background(), data, sig, pubKey)
require.NoError(t, err)
assert.Equal(t, string(keyID), signingKey)
}
func TestGPGVerifyInvalidSignature(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
data := []byte("test data to sign")
sig, _, err := gpgSign(context.Background(), data, keyID)
require.NoError(t, err)
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
require.NoError(t, err)
// Try to verify with different data - should fail
wrongData := []byte("different data")
_, err = gpgVerify(context.Background(), wrongData, sig, pubKey)
assert.Error(t, err)
}
func TestGPGVerifyBadPublicKey(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
data := []byte("test data")
sig, _, err := gpgSign(context.Background(), data, keyID)
require.NoError(t, err)
// Try to verify with invalid public key - should fail
badPubKey := []byte("not a valid public key")
_, err = gpgVerify(context.Background(), data, sig, badPubKey)
assert.Error(t, err)
}
func TestManifestSignatureVerification(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
// Create a builder with signing options
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{
KeyID: keyID,
})
// Add a test file
content := []byte("test file content for verification")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
var buf bytes.Buffer
err = b.Build(context.Background(), &buf)
require.NoError(t, err)
// Parse the manifest - signature should be verified during load
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
require.NotNil(t, manifest)
// Signature should be present and valid
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
}
func TestManifestTamperedSignatureFails(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
// Create a signed manifest
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{
KeyID: keyID,
})
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
var buf bytes.Buffer
err = b.Build(context.Background(), &buf)
require.NoError(t, err)
// Tamper with the signature by replacing some bytes
data := buf.Bytes()
// Find and modify a byte in the signature portion
for i := range data {
if i > 100 && data[i] == 'A' {
data[i] = 'B'
break
}
}
// Try to load the tampered manifest - should fail
_, err = NewManifestFromReader(bytes.NewReader(data))
assert.Error(t, err)
}
// TestManifestRefusesSecondEmbeddedKey loads a manifest whose embedded
// public key block holds another key before the key that signed it.
// Loading must refuse it, although the signature is good and the signer
// field names the key that made it.
func TestManifestRefusesSecondEmbeddedKey(t *testing.T) {
otherKey, otherHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", otherHome)
otherPubKey, err := gpgExportPublicKey(context.Background(), otherKey)
require.NoError(t, err)
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
func(outer *MFFileOuter) {
outer.SigningPubKey = slices.Concat(otherPubKey, outer.GetSigningPubKey())
})
_, err = NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errSigningKeyCount)
}
// TestManifestRefusesSecondEmbeddedKeyWithoutUserID loads a manifest whose
// embedded public key block holds, before the key that signed it, another
// key with its user ID removed, which gpg skips on import. Loading must
// refuse it: the block holds two keys.
func TestManifestRefusesSecondEmbeddedKeyWithoutUserID(t *testing.T) {
otherKey, otherHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", otherHome)
// Keeping only the user IDs that match "nobody" exports none.
otherPubKey, _, err := runGPG(context.Background(), nil,
gpgArgs([]string{
"--export", gpgOptArmor, "--export-filter", "keep-uid=uid = nobody",
}, string(otherKey))...)
require.NoError(t, err)
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
func(outer *MFFileOuter) {
outer.SigningPubKey = slices.Concat(
otherPubKey.Bytes(), outer.GetSigningPubKey())
})
_, err = NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errSigningKeyCount)
}
// TestManifestRefusesTwoSignatures loads a manifest whose signature field
// holds its good signature twice. Loading must refuse it.
func TestManifestRefusesTwoSignatures(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
func(outer *MFFileOuter) {
outer.Signature = slices.Concat(
outer.GetSignature(), outer.GetSignature())
})
_, err := NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errNotOneGoodSignature)
}
// TestManifestSignedWithSubkey signs with a key whose primary key can only
// certify, so gpg signs with its signing subkey. The manifest must load,
// with the primary key's fingerprint as signer.
func TestManifestSignedWithSubkey(t *testing.T) {
gpgHome := t.TempDir()
t.Setenv("GNUPGHOME", gpgHome)
genTestKey(t, gpgHome, "Key-Type: RSA\nKey-Length: 2048\nKey-Usage: cert\n"+
"Subkey-Type: RSA\nSubkey-Length: 2048\nSubkey-Usage: sign\n"+
"Expire-Date: 0\n")
primary, err := gpgGetKeyFingerprint(context.Background(), "test@mfer.test")
require.NoError(t, err)
m, err := NewManifestFromReader(bytes.NewReader(
signedTestManifest(t, GPGKeyID("test@mfer.test"))))
require.NoError(t, err)
assert.Equal(t, primary, m.pbOuter.GetSigner())
}
// TestManifestRefusesSignerOtherThanSigningKey loads a manifest whose
// signer field names a key other than the one that made the signature.
func TestManifestRefusesSignerOtherThanSigningKey(t *testing.T) {
keyID, gpgHome := testGPGEnv(t)
t.Setenv("GNUPGHOME", gpgHome)
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
func(outer *MFFileOuter) {
outer.Signer = []byte(strings.Repeat("A", len(keyID)))
})
_, err := NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errSignerNotSigningKey)
}
// TestBuilderSigningKeyIDMatchingTwoKeys signs with a key ID that two keys
// in the keyring match. The manifest must embed and name only the key that
// signed it, or loading refuses it.
func TestBuilderSigningKeyIDMatchingTwoKeys(t *testing.T) {
_, gpgHome := testGPGEnv(t)
genTestKey(t, gpgHome, testKeyParams)
t.Setenv("GNUPGHOME", gpgHome)
manifest := signedTestManifest(t, GPGKeyID("test@mfer.test"))
_, err := NewManifestFromReader(bytes.NewReader(manifest))
require.NoError(t, err)
}
// TestBuilderSigningUserIDWithExpiredFirstKey signs with a user ID whose
// first key in the keyring has expired. gpg signs with the other key for
// that user ID, and the manifest must name and embed that key.
func TestBuilderSigningUserIDWithExpiredFirstKey(t *testing.T) {
gpgHome := t.TempDir()
t.Setenv("GNUPGHOME", gpgHome)
// Made in 2020 and valid for one day.
genTestKey(t, gpgHome, "Key-Type: RSA\nKey-Length: 2048\n"+
"Creation-Date: 20200101T000000\nExpire-Date: 1d\n")
expired, err := gpgGetKeyFingerprint(context.Background(), "test@mfer.test")
require.NoError(t, err)
genTestKey(t, gpgHome, testKeyParams)
m, err := NewManifestFromReader(bytes.NewReader(
signedTestManifest(t, GPGKeyID("test@mfer.test"))))
require.NoError(t, err)
assert.NotEqual(t, expired, m.pbOuter.GetSigner())
}
func TestBuilderWithoutSigning(t *testing.T) {
t.Parallel()
// Create a builder without signing options
b := NewBuilder()
// Add a test file
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
var buf bytes.Buffer
err = b.Build(context.Background(), &buf)
require.NoError(t, err)
// Parse the manifest and verify signature fields are empty
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
require.NotNil(t, manifest.pbOuter)
assert.Empty(t, manifest.pbOuter.GetSignature(),
"signature should be empty when not signing")
assert.Empty(t, manifest.pbOuter.GetSigner(),
"signer should be empty when not signing")
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
"signing public key should be empty when not signing")
}
// fakeGPGPath writes script as an executable named gpg into a temporary
// directory and returns a PATH value with that directory first.
func fakeGPGPath(t *testing.T, script string) string {
t.Helper()
binDir := t.TempDir()
//nolint:gosec // G306: the fake gpg has to be executable
require.NoError(t, os.WriteFile(filepath.Join(binDir, "gpg"),
[]byte(script), 0o700))
return binDir + string(os.PathListSeparator) + os.Getenv("PATH")
}
// TestGPGTimeoutKillsGPG puts a fake gpg that never finishes first on
// PATH and checks that a run past its deadline is killed and reported as
// a timeout of the named operation, instead of hanging.
func TestGPGTimeoutKillsGPG(t *testing.T) {
t.Setenv("PATH", fakeGPGPath(t, "#!/bin/sh\nexec sleep 10\n"))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, _, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
}
// TestGPGCancelWhenChildHoldsOutput uses a fake gpg that runs sleep as a
// child instead of exec-ing it, the way a wrapper script around the real
// gpg might. Killing the fake gpg leaves sleep holding its stdout and
// stderr open; the call must still return once ctx ends instead of waiting
// for sleep to exit. The fake gpg writes the process ID of sleep to a named
// pipe; the test ends ctx only after reading it, so sleep is running by
// then, and kills sleep before returning.
func TestGPGCancelWhenChildHoldsOutput(t *testing.T) {
pidPipe := filepath.Join(t.TempDir(), "sleep.pid")
require.NoError(t, syscall.Mkfifo(pidPipe, 0o600))
// sleep outlasts the 10 s wait below, so a call that waits for it fails.
t.Setenv("PATH", fakeGPGPath(t,
"#!/bin/sh\nsleep 60 &\necho $! >'"+pidPipe+"'\nwait\n"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signErr := make(chan error, 1)
go func() {
_, _, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
signErr <- err
}()
pid, err := os.ReadFile(pidPipe) //nolint:gosec // G304: path inside t.TempDir()
require.NoError(t, err)
n, err := strconv.Atoi(strings.TrimSpace(string(pid)))
require.NoError(t, err)
sleep, err := os.FindProcess(n)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sleep.Kill()) })
cancel()
// The call should return about gpgWaitDelay (one second) after the
// cancel. 10 s is far above that and well under the 30 s test timeout,
// which would abort the whole package before the cleanup kills sleep.
select {
case err := <-signErr:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(10 * time.Second):
t.Fatal("the call waited for the child holding gpg's output to exit")
}
}
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg
// runs that sign a manifest through the context given to Build.
func TestBuildPassesContextToSigning(t *testing.T) {
t.Parallel()
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{KeyID: "any"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
) )
var ( var (
errOuterNotSet = errors.New("outer message not set") errOuterNotSet = errors.New("pbOuter not set")
errUUIDNotSet = errors.New("UUID not set") errUUIDNotSet = errors.New("UUID not set")
errSHA256NotSet = errors.New("SHA256 hash not set") errSHA256NotSet = errors.New("SHA256 hash not set")
) )
@@ -65,7 +65,7 @@ func (m *manifest) signatureString() (string, error) {
mh, err := multihash.Encode(m.pbOuter.GetSha256(), multihash.SHA2_256) mh, err := multihash.Encode(m.pbOuter.GetSha256(), multihash.SHA2_256)
if err != nil { if err != nil {
return "", fmt.Errorf("encode multihash: %w", err) return "", fmt.Errorf("failed to encode multihash: %w", err)
} }
uuidStr := hex.EncodeToString(m.pbOuter.GetUuid()) uuidStr := hex.EncodeToString(m.pbOuter.GetUuid())
-266
View File
@@ -1,266 +0,0 @@
package mfer
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"io"
"slices"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
const (
// The tags of OpenPGP signature, secret key and public key packets
// (RFC 9580, section 5). Subkeys have tags of their own, so each
// secret or public key packet is one primary key.
signaturePacketTag = 2
secretKeyPacketTag = 5
publicKeyPacketTag = 6
// armorEnd starts the line that ends an armored block.
armorEnd = "-----END "
)
var (
errKeyCount = errors.New("must hold exactly one key")
errNoSecretKey = errors.New("signing key file holds no secret key")
errNoPassphrase = errors.New(
"signing key is protected and no passphrase was given")
errNotOneSignature = errors.New(
"signature must hold exactly one signature")
)
// SigningOptions holds the key a manifest is signed with.
type SigningOptions struct {
// SecretKey is an OpenPGP secret key, armored or binary, as
// gpg --export-secret-keys writes it. It must hold one primary key.
SecretKey []byte
// Passphrase unlocks SecretKey when it is protected.
Passphrase []byte
}
// SecretKeyIsProtected reports whether the OpenPGP secret key secretKey,
// armored or binary, needs a passphrase to sign. It fails unless
// secretKey holds one primary key with its secret key.
func SecretKeyIsProtected(secretKey []byte) (bool, error) {
key, err := readSecretKey(secretKey)
if err != nil {
return false, err
}
return isProtected(key), nil
}
// readSigningKey returns the key in opts.SecretKey, unlocked with
// opts.Passphrase if it is protected.
func readSigningKey(opts *SigningOptions) (*openpgp.Entity, error) {
key, err := readSecretKey(opts.SecretKey)
if err != nil {
return nil, err
}
if !isProtected(key) {
return key, nil
}
if len(opts.Passphrase) == 0 {
return nil, errNoPassphrase
}
err = key.DecryptPrivateKeys(opts.Passphrase)
if err != nil {
return nil, fmt.Errorf("unlock signing key: %w", err)
}
return key, nil
}
// readSecretKey returns the one key in secretKey, armored or binary,
// which must include its secret key.
func readSecretKey(secretKey []byte) (*openpgp.Entity, error) {
key, err := readOneKey(secretKey, "signing key file")
if err != nil {
return nil, err
}
if key.PrivateKey == nil {
return nil, errNoSecretKey
}
return key, nil
}
// readOneKey returns the key in data, armored or binary, which must hold
// exactly one primary key. what names data in errors.
func readOneKey(data []byte, what string) (*openpgp.Entity, error) {
packets, err := dearmor(data)
if err != nil {
return nil, fmt.Errorf("read %s: %w", what, err)
}
keys, err := countPackets(packets, secretKeyPacketTag, publicKeyPacketTag)
if err != nil {
return nil, fmt.Errorf("read %s: %w", what, err)
}
if keys != 1 {
return nil, fmt.Errorf("%s %w, found %d", what, errKeyCount, keys)
}
keyring, err := openpgp.ReadKeyRing(bytes.NewReader(packets))
if err != nil {
return nil, fmt.Errorf("read %s: %w", what, err)
}
// openpgp.ReadKeyRing also reads a subkey packet at the start as a
// primary key.
if len(keyring) != 1 {
return nil, fmt.Errorf("%s %w, found %d", what, errKeyCount, len(keyring))
}
return keyring[0], nil
}
// isProtected reports whether any secret key in key needs a passphrase.
func isProtected(key *openpgp.Entity) bool {
if key.PrivateKey.Encrypted {
return true
}
for _, subkey := range key.Subkeys {
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
return true
}
}
return false
}
// armoredPublicKey returns the public part of key, armored.
func armoredPublicKey(key *openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
if err != nil {
return nil, err
}
err = key.Serialize(w)
if err != nil {
return nil, fmt.Errorf("write public key: %w", err)
}
err = w.Close()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// fingerprint returns the fingerprint of key's primary key in upper-case
// hex, as gpg prints it.
func fingerprint(key *openpgp.Entity) string {
return strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
}
// verifySignature checks that signature is one good OpenPGP signature
// over data, made by the one primary key in pubKey or one of its 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")
if err != nil {
return "", err
}
sigData, err := dearmor(signature)
if err != nil {
return "", fmt.Errorf("read signature: %w", err)
}
sigs, err := countPackets(sigData, signaturePacketTag)
if err != nil {
return "", fmt.Errorf("read signature: %w", err)
}
if sigs != 1 {
return "", fmt.Errorf("%w, found %d", errNotOneSignature, sigs)
}
_, err = openpgp.CheckDetachedSignature(openpgp.EntityList{key},
bytes.NewReader(data), bytes.NewReader(sigData), nil)
// A manifest outlives its signing key, so a signature by a key that
// has expired since is still good.
if err != nil && !errors.Is(err, pgperrors.ErrKeyExpired) {
return "", fmt.Errorf("verify signature: %w", err)
}
return fingerprint(key), nil
}
// dearmor returns the binary OpenPGP data in data: data itself when it is
// not armored, or else the bodies of all its armored blocks, one after
// another. armor.Decode reads only the first block it finds, so dearmor
// decodes the text after each block's END line in turn, and a second key
// or signature after the first is read too.
func dearmor(data []byte) ([]byte, error) {
var binary []byte
rest := data
for {
block, err := armor.Decode(bytes.NewReader(rest))
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
body, err := io.ReadAll(block.Body)
if err != nil {
return nil, err
}
binary = append(binary, body...)
_, rest, _ = bytes.Cut(rest, []byte(armorEnd))
}
if binary == nil {
return data, nil
}
return binary, nil
}
// countPackets returns how many packets in the binary OpenPGP data have
// one of tags. It reads only each packet's header, so it also counts
// packets that openpgp.ReadKeyRing skips, such as a key with no user ID
// or of an algorithm it does not know.
func countPackets(data []byte, tags ...uint8) (int, error) {
packets := packet.NewOpaqueReader(bytes.NewReader(data))
count := 0
for {
p, err := packets.Next()
if errors.Is(err, io.EOF) {
return count, nil
}
if err != nil {
return 0, err
}
if slices.Contains(tags, p.Tag) {
count++
}
}
}
-568
View File
@@ -1,568 +0,0 @@
//nolint:testpackage // white-box tests exercise unexported internals
package mfer
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
// newTestKey returns a new Ed25519 key, which is quick to make, for
// "MFER Test Key <test@mfer.test>". config may set when it is made and how
// long it lasts.
func newTestKey(t *testing.T, config *packet.Config) *openpgp.Entity {
t.Helper()
if config == nil {
config = &packet.Config{}
}
config.Algorithm = packet.PubKeyAlgoEdDSA
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test", config)
require.NoError(t, err)
return key
}
// armoredSecretKeys returns keys with their secret keys in one armored
// block, as gpg --export-secret-keys --armor writes them.
func armoredSecretKeys(t *testing.T, keys ...*openpgp.Entity) []byte {
t.Helper()
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
require.NoError(t, err)
for _, key := range keys {
require.NoError(t, key.SerializePrivateWithoutSigning(w, nil))
}
require.NoError(t, w.Close())
return buf.Bytes()
}
// armoredPublicKeys returns the public parts of keys in one armored block,
// as gpg --export --armor writes them.
func armoredPublicKeys(t *testing.T, keys ...*openpgp.Entity) []byte {
t.Helper()
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
require.NoError(t, err)
for _, key := range keys {
require.NoError(t, key.Serialize(w))
}
require.NoError(t, w.Close())
return buf.Bytes()
}
// testSigningOptions returns signing options for a new key with no
// passphrase.
func testSigningOptions(t *testing.T) *SigningOptions {
t.Helper()
return &SigningOptions{SecretKey: armoredSecretKeys(t, newTestKey(t, nil))}
}
// joinArmored returns the armored block first followed by the armored
// block second on the next line. armor.Encode ends a block without a
// newline, unlike gpg, and a block only starts at the start of a line.
func joinArmored(first, second []byte) []byte {
return slices.Concat(first, []byte("\n"), second)
}
// signedTestManifest returns a manifest of one file signed with opts.
func signedTestManifest(t *testing.T, opts *SigningOptions) []byte {
t.Helper()
b := NewBuilder()
b.SetSigningOptions(opts)
content := []byte("signed file content")
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0,
bytes.NewReader(content), nil)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, b.Build(context.Background(), &buf))
return buf.Bytes()
}
// rewriteOuter returns manifest with its outer message changed by edit.
// A signature stays good as long as edit leaves the UUID and hash alone.
func rewriteOuter(t *testing.T, manifest []byte, edit func(*MFFileOuter)) []byte {
t.Helper()
outer := new(MFFileOuter)
require.NoError(t, proto.Unmarshal(manifest[len(MAGIC):], outer))
edit(outer)
data, err := proto.Marshal(outer)
require.NoError(t, err)
return append([]byte(MAGIC), data...)
}
func TestBuilderWithSigning(t *testing.T) {
t.Parallel()
key := newTestKey(t, nil)
// Create a builder with signing options
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{SecretKey: armoredSecretKeys(t, key)})
// Add a test file
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
var buf bytes.Buffer
err = b.Build(context.Background(), &buf)
require.NoError(t, err)
// Parse the manifest and verify signature fields are populated
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
require.NotNil(t, manifest.pbOuter)
assert.NotEmpty(t, manifest.pbOuter.GetSignature(),
"signature should be populated")
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey(),
"signing public key should be populated")
// The signer is the key's fingerprint in 40 upper-case hex characters.
assert.Equal(t, fingerprint(key), string(manifest.pbOuter.GetSigner()))
assert.Regexp(t, "^[0-9A-F]{40}$", string(manifest.pbOuter.GetSigner()))
// Verify signature is a valid PGP signature
assert.Contains(t, string(manifest.pbOuter.GetSignature()),
"-----BEGIN PGP SIGNATURE-----")
// Verify public key is a valid PGP public key block
assert.Contains(t, string(manifest.pbOuter.GetSigningPubKey()),
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
}
func TestScannerWithSigning(t *testing.T) {
t.Parallel()
// Create in-memory filesystem with test files
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
require.NoError(t,
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
require.NoError(t,
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
// Create scanner with signing options
opts := &ScannerOptions{
Fs: fs,
SigningOptions: testSigningOptions(t),
}
s := NewScannerWithOptions(opts)
// Enumerate files
require.NoError(t, s.EnumeratePath("/testdir", nil))
assert.Equal(t, FileCount(2), s.FileCount())
// Generate signed manifest
var buf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
// Parse and verify
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
assert.NotEmpty(t, manifest.pbOuter.GetSigner())
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey())
}
// TestSigningWithBinarySecretKey signs with a secret key that is not
// armored, as gpg --export-secret-keys writes it without --armor.
func TestSigningWithBinarySecretKey(t *testing.T) {
t.Parallel()
var secretKey bytes.Buffer
require.NoError(t, newTestKey(t, nil).SerializePrivateWithoutSigning(&secretKey, nil))
_, err := NewManifestFromReader(bytes.NewReader(
signedTestManifest(t, &SigningOptions{SecretKey: secretKey.Bytes()})))
require.NoError(t, err)
}
// TestSigningWithProtectedKey signs with a key protected by a passphrase:
// with the passphrase, without one, and with a wrong one.
func TestSigningWithProtectedKey(t *testing.T) {
t.Parallel()
key := newTestKey(t, nil)
require.NoError(t, key.EncryptPrivateKeys([]byte("right"), nil))
secretKey := armoredSecretKeys(t, key)
protected, err := SecretKeyIsProtected(secretKey)
require.NoError(t, err)
assert.True(t, protected)
_, err = NewManifestFromReader(bytes.NewReader(signedTestManifest(t,
&SigningOptions{SecretKey: secretKey, Passphrase: []byte("right")})))
require.NoError(t, err)
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{SecretKey: secretKey})
require.ErrorIs(t, b.Build(context.Background(), io.Discard), errNoPassphrase)
b.SetSigningOptions(&SigningOptions{
SecretKey: secretKey, Passphrase: []byte("wrong"),
})
assert.ErrorContains(t, b.Build(context.Background(), io.Discard),
"unlock signing key")
}
func TestSecretKeyIsProtectedWithoutPassphrase(t *testing.T) {
t.Parallel()
protected, err := SecretKeyIsProtected(testSigningOptions(t).SecretKey)
require.NoError(t, err)
assert.False(t, protected)
}
// 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.
func TestSigningKeyFileWithTwoKeys(t *testing.T) {
t.Parallel()
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{SecretKey: armoredSecretKeys(t,
newTestKey(t, nil), newTestKey(t, nil))})
err := b.Build(context.Background(), io.Discard)
require.ErrorIs(t, err, errKeyCount)
assert.EqualError(t, err, "signing key file must hold exactly one key, found 2")
}
// TestSigningKeyFileWithoutSecretKey signs with a file that holds only a
// public key.
func TestSigningKeyFileWithoutSecretKey(t *testing.T) {
t.Parallel()
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{
SecretKey: armoredPublicKeys(t, newTestKey(t, nil)),
})
require.ErrorIs(t, b.Build(context.Background(), io.Discard), errNoSecretKey)
}
func TestVerifySignature(t *testing.T) {
t.Parallel()
key := newTestKey(t, nil)
data := []byte("test data to sign and verify")
var armored, binary bytes.Buffer
require.NoError(t, openpgp.ArmoredDetachSign(&armored, key,
bytes.NewReader(data), nil))
require.NoError(t, openpgp.DetachSign(&binary, key, bytes.NewReader(data), nil))
pubKey, err := armoredPublicKey(key)
require.NoError(t, err)
var binaryPubKey bytes.Buffer
require.NoError(t, key.Serialize(&binaryPubKey))
// Verifying names the key that made the signature, whether the
// signature and key are armored or not.
signer, err := verifySignature(data, armored.Bytes(), pubKey)
require.NoError(t, err)
assert.Equal(t, fingerprint(key), signer)
signer, err = verifySignature(data, binary.Bytes(), binaryPubKey.Bytes())
require.NoError(t, err)
assert.Equal(t, fingerprint(key), signer)
// A signature over other data is bad.
_, err = verifySignature([]byte("different data"), armored.Bytes(), pubKey)
require.Error(t, err)
// A public key that is not one cannot verify anything.
_, err = verifySignature(data, armored.Bytes(), []byte("not a public key"))
assert.Error(t, err)
}
// TestVerifySignatureKeyExpiredSince verifies a signature made in 2020 by
// a key that expired a day after it was made. The signature is still good.
func TestVerifySignatureKeyExpiredSince(t *testing.T) {
t.Parallel()
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
config := &packet.Config{
Time: func() time.Time { return made },
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
}
key := newTestKey(t, config)
data := []byte("signed in 2020")
var sig bytes.Buffer
require.NoError(t, openpgp.ArmoredDetachSign(&sig, key, bytes.NewReader(data), config))
pubKey, err := armoredPublicKey(key)
require.NoError(t, err)
signer, err := verifySignature(data, sig.Bytes(), pubKey)
require.NoError(t, err)
assert.Equal(t, fingerprint(key), signer)
}
func TestManifestSignatureVerification(t *testing.T) {
t.Parallel()
// Parse the manifest - signature should be verified during load
manifest, err := NewManifestFromReader(bytes.NewReader(
signedTestManifest(t, testSigningOptions(t))))
require.NoError(t, err)
require.NotNil(t, manifest)
// Signature should be present and valid
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
}
func TestManifestTamperedSignatureFails(t *testing.T) {
t.Parallel()
// Change one character of the signature's base64 body, which starts
// after the blank line that ends the armor headers.
data := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
func(outer *MFFileOuter) {
sig := outer.GetSignature()
i := bytes.Index(sig, []byte("\n\n")) + len("\n\n") + 20
sig[i]++
})
// Try to load the tampered manifest - should fail
_, err := NewManifestFromReader(bytes.NewReader(data))
assert.Error(t, err)
}
// TestManifestSignedByGPGLoads loads the signed seed of
// FuzzNewManifestFromReader, a manifest signed with gpg before mfer signed
// and verified manifests itself.
func TestManifestSignedByGPGLoads(t *testing.T) {
t.Parallel()
seed, err := os.ReadFile(filepath.Join(
"testdata", "fuzz", "FuzzNewManifestFromReader", "signed"))
require.NoError(t, err)
// After its header line the seed holds the manifest as []byte("...").
_, quoted, found := strings.Cut(string(seed), "[]byte(")
require.True(t, found)
manifest, err := strconv.Unquote(
strings.TrimSuffix(strings.TrimSpace(quoted), ")"))
require.NoError(t, err)
m, err := NewManifestFromReader(strings.NewReader(manifest))
require.NoError(t, err)
assert.Equal(t, "4F562BFB863FDC6B51B4EE88872A51176CEF23AE",
string(m.pbOuter.GetSigner()))
}
// TestManifestRefusesSecondEmbeddedKey loads manifests whose embedded
// public key block holds another key besides the key that signed it: in an
// armored block of its own, in the same armored block, as a secret key
// with no user ID, which openpgp.ReadKeyRing skips, and written as a
// subkey packet at the start of the block, which openpgp.ReadKeyRing reads
// as a primary key. Loading must refuse each, although the signature is
// good and the signer field names the key that made it.
func TestManifestRefusesSecondEmbeddedKey(t *testing.T) {
t.Parallel()
other := newTestKey(t, nil)
signer := newTestKey(t, nil)
manifest := signedTestManifest(t,
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)})
otherWithoutUserID := newTestKey(t, nil)
otherWithoutUserID.Identities = map[string]*openpgp.Identity{}
otherAsSubkey := newTestKey(t, nil)
otherAsSubkey.PrimaryKey.IsSubkey = true
for name, block := range map[string][]byte{
"armored block of its own": joinArmored(
armoredPublicKeys(t, other), armoredPublicKeys(t, signer)),
"same armored block": armoredPublicKeys(t, other, signer),
"secret key without user ID": joinArmored(
armoredPublicKeys(t, signer), armoredSecretKeys(t, otherWithoutUserID)),
"subkey packet first": armoredPublicKeys(t, otherAsSubkey, signer),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
embedded := rewriteOuter(t, manifest, func(outer *MFFileOuter) {
outer.SigningPubKey = block
})
_, err := NewManifestFromReader(bytes.NewReader(embedded))
require.ErrorIs(t, err, errKeyCount)
})
}
}
// TestManifestRefusesSecondEmbeddedKeyWithoutUserID loads a manifest whose
// embedded public key block holds, before the key that signed it, another
// key with no user ID, which openpgp.ReadKeyRing skips. Loading must
// refuse it: the block holds two keys.
func TestManifestRefusesSecondEmbeddedKeyWithoutUserID(t *testing.T) {
t.Parallel()
other := newTestKey(t, nil)
other.Identities = map[string]*openpgp.Identity{}
manifest := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
func(outer *MFFileOuter) {
outer.SigningPubKey = joinArmored(
armoredPublicKeys(t, other), outer.GetSigningPubKey())
})
_, err := NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errKeyCount)
}
// TestManifestRefusesTwoSignatures loads a manifest whose signature field
// holds its good signature twice. Loading must refuse it.
func TestManifestRefusesTwoSignatures(t *testing.T) {
t.Parallel()
manifest := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
func(outer *MFFileOuter) {
outer.Signature = joinArmored(
outer.GetSignature(), outer.GetSignature())
})
_, err := NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errNotOneSignature)
}
// TestManifestSignedWithSubkey signs with a key that has a signing subkey,
// which signs in place of the primary key. The manifest must load, with
// the primary key's fingerprint as signer.
func TestManifestSignedWithSubkey(t *testing.T) {
t.Parallel()
key := newTestKey(t, nil)
require.NoError(t, key.AddSigningSubkey(
&packet.Config{Algorithm: packet.PubKeyAlgoEdDSA}))
m, err := NewManifestFromReader(bytes.NewReader(signedTestManifest(t,
&SigningOptions{SecretKey: armoredSecretKeys(t, key)})))
require.NoError(t, err)
assert.Equal(t, fingerprint(key), string(m.pbOuter.GetSigner()))
block, err := armor.Decode(bytes.NewReader(m.pbOuter.GetSignature()))
require.NoError(t, err)
p, err := packet.Read(block.Body)
require.NoError(t, err)
sig, ok := p.(*packet.Signature)
require.True(t, ok)
subkey := key.Subkeys[len(key.Subkeys)-1].PublicKey
assert.Equal(t, subkey.KeyId, *sig.IssuerKeyId,
"the signing subkey made the signature")
}
// TestManifestRefusesSignerOtherThanSigningKey loads a manifest whose
// signer field names a key other than the one that made the signature.
func TestManifestRefusesSignerOtherThanSigningKey(t *testing.T) {
t.Parallel()
manifest := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
func(outer *MFFileOuter) {
outer.Signer = []byte(strings.Repeat("A", len(outer.GetSigner())))
})
_, err := NewManifestFromReader(bytes.NewReader(manifest))
require.ErrorIs(t, err, errSignerNotSigningKey)
}
func TestBuilderWithoutSigning(t *testing.T) {
t.Parallel()
// Create a builder without signing options
b := NewBuilder()
// Add a test file
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
var buf bytes.Buffer
err = b.Build(context.Background(), &buf)
require.NoError(t, err)
// Parse the manifest and verify signature fields are empty
manifest, err := NewManifestFromReader(&buf)
require.NoError(t, err)
require.NotNil(t, manifest.pbOuter)
assert.Empty(t, manifest.pbOuter.GetSignature(),
"signature should be empty when not signing")
assert.Empty(t, manifest.pbOuter.GetSigner(),
"signer should be empty when not signing")
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
"signing public key should be empty when not signing")
}
// TestBuildPassesContextToSigning checks that Build does not sign once the
// context given to it has ended.
func TestBuildPassesContextToSigning(t *testing.T) {
t.Parallel()
b := NewBuilder()
b.SetSigningOptions(&SigningOptions{SecretKey: []byte("any")})
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
}
+5 -32
View File
@@ -2,7 +2,6 @@ package mfer
import ( import (
"context" "context"
"fmt"
"io" "io"
"io/fs" "io/fs"
"os" "os"
@@ -58,7 +57,7 @@ type ScannerOptions struct {
IncludePermissions bool IncludePermissions bool
// Fs is the filesystem to use, defaults to OsFs if nil. // Fs is the filesystem to use, defaults to OsFs if nil.
Fs afero.Fs Fs afero.Fs
// SigningOptions holds the key to sign with (nil = no signing). // SigningOptions holds GPG signing options (nil = no signing).
SigningOptions *SigningOptions SigningOptions *SigningOptions
// Seed, if set, derives a deterministic UUID from this seed. // Seed, if set, derives a deterministic UUID from this seed.
Seed string Seed string
@@ -80,8 +79,7 @@ type FileEntry struct {
type Scanner struct { type Scanner struct {
mu sync.RWMutex mu sync.RWMutex
files []*FileEntry files []*FileEntry
paths map[RelFilePath]AbsFilePath // the file at each path in files totalBytes FileSize // cached sum of all file sizes
totalBytes FileSize // cached sum of all file sizes
options *ScannerOptions options *ScannerOptions
fs afero.Fs fs afero.Fs
excluded []fs.FileInfo // the files named in ExcludePaths that exist excluded []fs.FileInfo // the files named in ExcludePaths that exist
@@ -105,7 +103,6 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
s := &Scanner{ s := &Scanner{
files: make([]*FileEntry, 0), files: make([]*FileEntry, 0),
paths: make(map[RelFilePath]AbsFilePath),
options: opts, options: opts,
fs: fs, fs: fs,
} }
@@ -160,9 +157,7 @@ func (s *Scanner) EnumeratePath(
return s.enumerateFS(afs, abs, progress) return s.enumerateFS(afs, abs, progress)
} }
// EnumeratePaths adds to the scanner the files under each directory path, // EnumeratePaths walks multiple directory paths and adds all files to the scanner.
// listed by their paths under it, and each file path, listed by its name
// as EnumerateFile lists it.
// If progress is non-nil, status updates are sent as files are discovered. // If progress is non-nil, status updates are sent as files are discovered.
// The progress channel is closed when the method returns. // The progress channel is closed when the method returns.
func (s *Scanner) EnumeratePaths( func (s *Scanner) EnumeratePaths(
@@ -179,19 +174,9 @@ func (s *Scanner) EnumeratePaths(
return err return err
} }
info, err := s.fs.Stat(abs) afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
if err != nil {
return err
}
if info.IsDir() {
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
err = s.enumerateFS(afs, abs, progress)
} else {
err = s.enumerateFileWithInfo(
filepath.Base(abs), filepath.Dir(abs), info, progress)
}
err = s.enumerateFS(afs, abs, progress)
if err != nil { if err != nil {
return err return err
} }
@@ -493,18 +478,6 @@ func (s *Scanner) enumerateFileWithInfo(
} }
s.mu.Lock() s.mu.Lock()
// Each path is relative to the input path it was found under, so files
// under two input paths can share one.
first, ok := s.paths[entry.Path]
if ok {
s.mu.Unlock()
return fmt.Errorf("%w %q: %s and %s",
errDuplicatePath, entry.Path, first, entry.AbsPath)
}
s.paths[entry.Path] = entry.AbsPath
s.files = append(s.files, entry) s.files = append(s.files, entry)
s.totalBytes += entry.Size s.totalBytes += entry.Size
filesFound := FileCount(len(s.files)) filesFound := FileCount(len(s.files))
-22
View File
@@ -134,28 +134,6 @@ func TestScannerEnumeratePaths(t *testing.T) {
assert.Equal(t, FileCount(2), s.FileCount()) assert.Equal(t, FileCount(2), s.FileCount())
} }
// TestScannerEnumeratePathsFile gives EnumeratePaths a directory and a
// file: the file is listed by its name, as EnumerateFile lists it.
func TestScannerEnumeratePathsFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/dir/sub", 0o755))
require.NoError(t, fs.MkdirAll("/other", 0o755))
require.NoError(t, afero.WriteFile(fs, "/dir/sub/one.txt", []byte("1"), 0o644))
require.NoError(t, afero.WriteFile(fs, "/other/two.txt", []byte("2"), 0o644))
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
require.NoError(t, s.EnumeratePaths(nil, "/dir", "/other/two.txt"))
paths := make([]RelFilePath, 0, s.FileCount())
for _, f := range s.Files() {
paths = append(paths, f.Path)
}
assert.Equal(t, []RelFilePath{"sub/one.txt", "two.txt"}, paths)
}
func TestScannerExcludeDotfiles(t *testing.T) { func TestScannerExcludeDotfiles(t *testing.T) {
t.Parallel() t.Parallel()
+27 -31
View File
@@ -7,11 +7,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"strings"
"time" "time"
"uuid" "uuid"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
@@ -22,9 +20,11 @@ const MAGIC string = "ZNAVSRFG"
var ( var (
// errInnerNotSet is returned by generate when the inner manifest is // errInnerNotSet is returned by generate when the inner manifest is
// missing. // missing.
errInnerNotSet = errors.New("inner message not set") errInnerNotSet = errors.New("internal error: pbInner not set")
// errInternal is returned by generateOuter for the same condition. // errInternal is returned by generateOuter for the same condition.
errInternal = errors.New("inner message not set") // The two messages differ, and both are load-bearing for callers that
// match on text, so they are kept distinct.
errInternal = errors.New("internal error")
) )
// nanosecondsInt32 converts t's nanosecond component to int32. // nanosecondsInt32 converts t's nanosecond component to int32.
@@ -65,14 +65,14 @@ func (m *manifest) generate(ctx context.Context) error {
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter) dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
if err != nil { if err != nil {
return fmt.Errorf("marshal outer message: %w", err) return fmt.Errorf("serialize: marshal outer: %w", err)
} }
m.output = bytes.NewBufferString(MAGIC) m.output = bytes.NewBufferString(MAGIC)
_, err = m.output.Write(dat) _, err = m.output.Write(dat)
if err != nil { if err != nil {
return fmt.Errorf("write outer message: %w", err) return fmt.Errorf("serialize: write output: %w", err)
} }
return nil return nil
@@ -95,7 +95,7 @@ func (m *manifest) generateOuter(ctx context.Context) error {
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner) innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
if err != nil { if err != nil {
return fmt.Errorf("marshal inner message: %w", err) return fmt.Errorf("serialize: marshal inner: %w", err)
} }
// Compress the inner data // Compress the inner data
@@ -103,12 +103,12 @@ func (m *manifest) generateOuter(ctx context.Context) error {
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
if err != nil { if err != nil {
return fmt.Errorf("create compressor: %w", err) return fmt.Errorf("serialize: create compressor: %w", err)
} }
_, err = zw.Write(innerData) _, err = zw.Write(innerData)
if err != nil { if err != nil {
return fmt.Errorf("compress inner message: %w", err) return fmt.Errorf("serialize: compress: %w", err)
} }
_ = zw.Close() _ = zw.Close()
@@ -120,7 +120,7 @@ func (m *manifest) generateOuter(ctx context.Context) error {
_, err = h.Write(compressedData) _, err = h.Write(compressedData)
if err != nil { if err != nil {
return fmt.Errorf("hash inner message: %w", err) return fmt.Errorf("serialize: hash write: %w", err)
} }
sha256Hash := h.Sum(nil) sha256Hash := h.Sum(nil)
@@ -135,48 +135,44 @@ func (m *manifest) generateOuter(ctx context.Context) error {
} }
// Sign the manifest if signing options are provided // Sign the manifest if signing options are provided
if m.signingOptions != nil { if m.signingOptions != nil && m.signingOptions.KeyID != "" {
return m.signOuter(ctx) return m.signOuter(ctx)
} }
return nil return nil
} }
// signOuter signs the outer message with the secret key in the signing // signOuter signs the outer message with the configured GPG key and
// options and embeds the signature, the key's fingerprint and its public // embeds the signature, signer fingerprint, and public key. The signer
// key. // and public key are those of the key gpg reports it signed with, so that
// a key ID matching more than one key cannot name or embed another key.
func (m *manifest) signOuter(ctx context.Context) error { func (m *manifest) signOuter(ctx context.Context) error {
// Unlocking a protected key can take a while; do not start once ctx
// has ended.
err := ctx.Err()
if err != nil {
return err
}
sigString, err := m.signatureString() sigString, err := m.signatureString()
if err != nil { if err != nil {
return fmt.Errorf("build signature string: %w", err) return fmt.Errorf("failed to generate signature string: %w", err)
} }
key, err := readSigningKey(m.signingOptions) sig, signingKey, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to sign manifest: %w", err)
} }
var sig bytes.Buffer m.pbOuter.Signature = sig
err = openpgp.ArmoredDetachSign(&sig, key, strings.NewReader(sigString), nil) // Listing the signing key, a subkey's included, puts its primary key's
// fingerprint first.
fingerprint, err := gpgGetKeyFingerprint(ctx, GPGKeyID(signingKey))
if err != nil { if err != nil {
return fmt.Errorf("sign manifest: %w", err) return fmt.Errorf("failed to get key fingerprint: %w", err)
} }
pubKey, err := armoredPublicKey(key) m.pbOuter.Signer = fingerprint
pubKey, err := gpgExportPublicKey(ctx, GPGKeyID(fingerprint))
if err != nil { if err != nil {
return err return fmt.Errorf("failed to export public key: %w", err)
} }
m.pbOuter.Signature = sig.Bytes()
m.pbOuter.Signer = []byte(fingerprint(key))
m.pbOuter.SigningPubKey = pubKey m.pbOuter.SigningPubKey = pubKey
return nil return nil