Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3f532c8d | ||
|
|
0baf83deb6 | ||
|
|
589f1a9580 | ||
|
|
7542a68863 | ||
|
|
6540e5f285 | ||
|
|
2ac238d767 | ||
|
|
2cfea66d27 | ||
|
|
8ec3fd9a67 | ||
|
|
0057f6463b | ||
|
|
7abe9a0ab1 | ||
|
|
9199444d11 | ||
|
|
60dbcc03bc | ||
|
|
a79dd463f1 | ||
|
|
24496880c5 | ||
|
|
61238e4071 | ||
|
|
f68cc284ac | ||
|
|
e633d3a31b | ||
|
|
c36f27068f | ||
|
|
8f7170b77f | ||
|
|
0fbb3da0a5 | ||
|
|
c23367c216 | ||
|
|
a5f218e42a | ||
|
|
6229c4eca0 | ||
|
|
e35cd4a045 | ||
|
|
c0b099cc48 | ||
|
|
dce5e050c3 | ||
|
|
4fe1ff2fe1 | ||
|
|
01ff67a38e | ||
|
|
f663f4242d | ||
|
|
0762a728d4 |
@@ -9,13 +9,23 @@ 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](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
[in the format specification](docs/FORMAT.md), which refers to the protobuf
|
||||||
which is included in the [project repository](https://git.eeqj.de/sneak/mfer).
|
schema `mfer/mf.proto` for exact field numbers and types. Both are included in
|
||||||
|
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
|
||||||
used for releases.
|
used for releases.
|
||||||
|
|
||||||
|
A version is a git tag, and no file in the tree records a version number.
|
||||||
|
`mfer version` prints the revision the build stamped into the binary, which for
|
||||||
|
`make build` and the Docker image is the output of `git describe` on the
|
||||||
|
checkout they build. A binary built without that stamp, as
|
||||||
|
`go install sneak.berlin/go/mfer/cmd/mfer@<tag>` builds it, prints the module
|
||||||
|
version Go records in it instead. A program that imports the `mfer` package
|
||||||
|
finds the version it was built against in its own build information
|
||||||
|
(`runtime/debug.ReadBuildInfo`).
|
||||||
|
|
||||||
This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
|
This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
|
||||||
2022 and is currently a one-person effort, though the goal is for this to emerge
|
2022 and is currently a one-person effort, though the goal is for this to emerge
|
||||||
as a de-facto standard and be incorporated into other software. A compatible
|
as a de-facto standard and be incorporated into other software. A compatible
|
||||||
@@ -256,8 +266,9 @@ 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))? Still open,
|
[golang implementation](https://github.com/frankbraun/gosignify))? Settled
|
||||||
as question 10 on [issue 82](https://git.eeqj.de/sneak/mfer/issues/82).
|
under 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`.
|
||||||
@@ -266,15 +277,55 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
|||||||
|
|
||||||
- `mfer gen` / `mfer gen .`
|
- `mfer gen` / `mfer gen .`
|
||||||
- recurses under current directory and writes out an `index.mf`
|
- recurses under current directory and writes out an `index.mf`
|
||||||
|
- lists regular files only, and skips a named pipe, socket or device, which
|
||||||
|
it names with `-v`
|
||||||
- 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`
|
||||||
- warns about each file under the base directory that the manifest does not
|
- looks for those files under the base directory: the one `--base` names, or
|
||||||
list, hidden files included; with `--no-extra-files` each one is a failure
|
else the directory holding the manifest, or the current directory for a
|
||||||
instead
|
manifest given by URL. So `mfer check /media/drive` checks a drive against
|
||||||
|
the `index.mf` at its root, from any directory
|
||||||
|
- warns about each regular file under the base directory that the manifest
|
||||||
|
does not list, hidden files included, and each symlink there to a regular
|
||||||
|
file, which gen lists only with `--follow-symlinks`; with
|
||||||
|
`--no-extra-files` each one is a failure instead. A named pipe, socket or
|
||||||
|
device, a symlink to a directory and a broken symlink are never reported,
|
||||||
|
since gen cannot list them
|
||||||
|
- `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
|
||||||
|
- lists regular files only, as gen does
|
||||||
|
- leaves out hidden files unless given `--include-dotfiles`, and symlinks
|
||||||
|
unless given `--follow-symlinks`, which lists each symlink to a regular
|
||||||
|
file under its own name with the contents of the file it points to
|
||||||
|
- refuses a signed manifest, leaving it as it was and naming the fingerprint
|
||||||
|
of the key that signed it, unless `--sign-key` or `MFER_SIGN_KEY` names a
|
||||||
|
key to sign the new one with
|
||||||
|
- `mfer gen --sign-key key.asc` / `mfer freshen --sign-key key.asc`
|
||||||
|
- signs the manifest with the OpenPGP secret key in `key.asc`, armored or
|
||||||
|
binary, as `gpg --export-secret-keys` writes it; `MFER_SIGN_KEY` names the
|
||||||
|
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. The key must be a version 4 key, whose 40-character fingerprint is
|
||||||
|
what `--require-signature` takes, and not a DSA key. A key that cannot
|
||||||
|
sign, because it has expired or been revoked or its passphrase is wrong,
|
||||||
|
stops `gen` and `freshen` before they read any file
|
||||||
|
- 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
|
||||||
|
|||||||
+4
-6
@@ -10,14 +10,12 @@ import (
|
|||||||
// Appname is the name of this program.
|
// Appname is the name of this program.
|
||||||
const Appname = "mfer"
|
const Appname = "mfer"
|
||||||
|
|
||||||
// Version and Gitrev are injected at build time via -ldflags.
|
// Gitrev is the revision `mfer version` prints, injected at build time via
|
||||||
|
// -ldflags.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // set via ldflags at build time
|
//nolint:gochecknoglobals // set via ldflags at build time
|
||||||
var (
|
var Gitrev string
|
||||||
Version string
|
|
||||||
Gitrev string
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(cli.Run(Appname, Version, Gitrev))
|
os.Exit(cli.Run(Appname, Gitrev))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ func TestAppname(t *testing.T) {
|
|||||||
assert.Equal(t, "mfer", Appname)
|
assert.Equal(t, "mfer", Appname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionDefaults documents that Version and Gitrev are empty unless
|
// TestGitrevDefault documents that Gitrev is empty unless injected at build
|
||||||
// injected at build time via -ldflags.
|
// time via -ldflags.
|
||||||
func TestVersionDefaults(t *testing.T) {
|
func TestGitrevDefault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
assert.Empty(t, Version)
|
|
||||||
assert.Empty(t, Gitrev)
|
assert.Empty(t, Gitrev)
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-38
@@ -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
|
||||||
GPG signatures for integrity verification and optional timestamps and file
|
OpenPGP 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
|
||||||
@@ -20,9 +20,14 @@ An `.mf` file consists of two parts, concatenated:
|
|||||||
2. **Outer message**: a Protocol Buffers serialized `MFFileOuter` message
|
2. **Outer message**: a Protocol Buffers serialized `MFFileOuter` message
|
||||||
|
|
||||||
There is no length prefix or version byte between the magic and the protobuf
|
There is no length prefix or version byte between the magic and the protobuf
|
||||||
message. The protobuf message extends to the end of the file.
|
message. The protobuf message extends to the end of the file, so a `.mf` is
|
||||||
|
always a whole file; a different framing, such as one with a length prefix,
|
||||||
|
would need a different magic. The format's version is the outer message's
|
||||||
|
`version` field.
|
||||||
|
|
||||||
See [`mfer/mf.proto`](../mfer/mf.proto) for exact field numbers and types.
|
See [`mfer/mf.proto`](../mfer/mf.proto) for exact field numbers and types.
|
||||||
|
Readers ignore any field they do not know, in any message, so a later version
|
||||||
|
can add a field without breaking 1.0 readers.
|
||||||
|
|
||||||
## Outer Message (`MFFileOuter`)
|
## Outer Message (`MFFileOuter`)
|
||||||
|
|
||||||
@@ -34,11 +39,11 @@ The outer message contains:
|
|||||||
| `compressionType` | 102 | enum | Compression of `innerMessage`; must be `COMPRESSION_ZSTD` (1) |
|
| `compressionType` | 102 | enum | Compression of `innerMessage`; must be `COMPRESSION_ZSTD` (1) |
|
||||||
| `size` | 103 | int64 | Uncompressed size of `innerMessage` (corruption detection) |
|
| `size` | 103 | int64 | Uncompressed size of `innerMessage` (corruption detection) |
|
||||||
| `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 | 16-byte 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) | GPG signature (ASCII-armored or binary) |
|
| `signature` | 201 | bytes (optional) | One OpenPGP signature over the canonical string, armored or binary |
|
||||||
| `signer` | 202 | bytes (optional) | Full GPG key ID of the signer |
|
| `signer` | 202 | bytes (optional) | Fingerprint of the signing primary key, in upper-case hex |
|
||||||
| `signingPubKey` | 203 | bytes (optional) | Full GPG signing public key |
|
| `signingPubKey` | 203 | bytes (optional) | Full OpenPGP public key of the signing key (ASCII-armored or binary) |
|
||||||
|
|
||||||
### SHA-256 Hash
|
### SHA-256 Hash
|
||||||
|
|
||||||
@@ -64,26 +69,26 @@ for the signature, the signing key and the other outer fields.
|
|||||||
After decompressing `innerMessage`, the result is a serialized `MFFile`
|
After decompressing `innerMessage`, the result is a serialized `MFFile`
|
||||||
(referred to as the manifest):
|
(referred to as the manifest):
|
||||||
|
|
||||||
| Field | Number | Type | Description |
|
| Field | Number | Type | Description |
|
||||||
| ----------- | ------ | --------------------- | ------------------------------------- |
|
| ----------- | ------ | --------------------- | ----------------------------------- |
|
||||||
| `version` | 100 | enum | Must be `VERSION_ONE` (1) |
|
| `version` | 100 | enum | Must be `VERSION_ONE` (1) |
|
||||||
| `files` | 101 | repeated `MFFilePath` | List of files in the manifest |
|
| `files` | 101 | repeated `MFFilePath` | List of files in the manifest |
|
||||||
| `uuid` | 102 | bytes | Random v4 UUID; must match outer UUID |
|
| `uuid` | 102 | bytes | 16-byte UUID; must match outer UUID |
|
||||||
| `createdAt` | 201 | Timestamp (optional) | When the manifest was created |
|
| `createdAt` | 201 | Timestamp (optional) | When the manifest was created |
|
||||||
|
|
||||||
## File Entries (`MFFilePath`)
|
## File Entries (`MFFilePath`)
|
||||||
|
|
||||||
Each file entry contains:
|
Each file entry contains:
|
||||||
|
|
||||||
| Field | Number | Type | Description |
|
| Field | Number | Type | Description |
|
||||||
| ---------- | ------ | ------------------------- | ----------------------------------- |
|
| ---------- | ------ | ------------------------- | ------------------------------------------------------------- |
|
||||||
| `path` | 1 | string | Relative file path (see Path Rules) |
|
| `path` | 1 | string | Relative file path (see Path Rules) |
|
||||||
| `size` | 2 | int64 | File size in bytes |
|
| `size` | 2 | int64 | File size in bytes |
|
||||||
| `hashes` | 3 | repeated `MFFileChecksum` | At least one hash required |
|
| `hashes` | 3 | repeated `MFFileChecksum` | At least one hash required; readers reject an entry with none |
|
||||||
| `mimeType` | 301 | string (optional) | MIME type |
|
| `mimeType` | 301 | string (optional) | MIME type |
|
||||||
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
||||||
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
||||||
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
||||||
|
|
||||||
## File Mode
|
## File Mode
|
||||||
|
|
||||||
@@ -107,11 +112,19 @@ All `path` values must satisfy these invariants:
|
|||||||
- **Forward slashes**: use `/` as the path separator (never `\`)
|
- **Forward slashes**: use `/` as the path separator (never `\`)
|
||||||
- **Relative only**: no leading `/`
|
- **Relative only**: no leading `/`
|
||||||
- **No parent traversal**: no `..` path segments
|
- **No parent traversal**: no `..` path segments
|
||||||
|
- **No current-directory segments**: 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.
|
manifests. Paths that violate these rules must be rejected, and a reader must
|
||||||
|
reject a manifest that lists a path more than once.
|
||||||
|
|
||||||
|
A path holds each name exactly as the file system gives it, with no Unicode
|
||||||
|
normalization, and readers compare paths byte for byte. So `é` stored as one
|
||||||
|
code point and `é` stored as `e` followed by a combining accent are two paths.
|
||||||
|
|
||||||
## Hash Format (`MFFileChecksum`)
|
## Hash Format (`MFFileChecksum`)
|
||||||
|
|
||||||
@@ -125,34 +138,243 @@ Implementations must be able to verify SHA-256 multihashes at minimum.
|
|||||||
|
|
||||||
## Signature Scheme
|
## Signature Scheme
|
||||||
|
|
||||||
Signing is optional. When present, the signature covers a canonical string
|
Signing is optional. A signature is stored only in the outer message's
|
||||||
constructed as:
|
`signature` field, never in a separate file. It is one OpenPGP signature over
|
||||||
|
the canonical string below, armored or binary, and does not contain the string,
|
||||||
|
which a verifier builds again from the outer message.
|
||||||
|
|
||||||
|
The canonical string is three parts separated by hyphens (`-`):
|
||||||
|
|
||||||
```
|
```
|
||||||
ZNAVSRFG-<UUID>-<SHA256>
|
ZNAVSRFG-<UUID>-<SHA256>
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
- `ZNAVSRFG` is the magic, in ASCII.
|
||||||
|
- `<UUID>` is the 16 bytes of the outer message's `uuid` as 32 lower-case hex
|
||||||
|
digits, with no hyphens.
|
||||||
|
- `<SHA256>` is the lower-case hex of the outer message's `sha256` encoded as a
|
||||||
|
SHA-256 multihash (see Hash Format). That multihash is the algorithm code
|
||||||
|
`0x12`, the digest length `0x20` and the 32 bytes of the hash, so this part is
|
||||||
|
68 hex digits starting `1220`.
|
||||||
|
|
||||||
- `ZNAVSRFG` is the magic bytes string (literal ASCII)
|
The signature covers exactly the 110 ASCII characters of the string, with no
|
||||||
- `<UUID>` is the hex-encoded UUID from the outer message
|
line ending. For example, the UUID `b0fb6918-6298-443b-ada2-1b162f4dc0b3` and a
|
||||||
- `<SHA256>` is the hex-encoded SHA-256 hash from the outer message (covering
|
`sha256` of `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
|
||||||
compressed data)
|
in hex give this canonical string:
|
||||||
|
|
||||||
Components are separated by hyphens. The signature is produced by GPG over this
|
```
|
||||||
canonical string and stored in the `signature` field of the outer message.
|
ZNAVSRFG-b0fb69186298443bada21b162f4dc0b3-1220e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||||
|
```
|
||||||
|
|
||||||
|
The signature covers `innerMessage` only through `sha256`, so a verifier checks
|
||||||
|
`sha256` against `innerMessage` (see SHA-256 Hash) as well as the signature.
|
||||||
|
|
||||||
|
The signing key's public key goes in `signingPubKey` and the fingerprint of its
|
||||||
|
primary key, in upper-case hex, in `signer`. The reference implementation signs
|
||||||
|
only with version 4 keys, whose fingerprints are 40 hex digits, and writes both
|
||||||
|
the signature and the public key armored.
|
||||||
|
|
||||||
|
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
|
||||||
|
that key (or one of its subkeys), and `signer` is that primary key's
|
||||||
|
fingerprint, compared without regard to case. The reference implementation
|
||||||
|
refuses to load a manifest that fails these checks; `check` and `fetch` given
|
||||||
|
`--require-signature` then compare the required fingerprint with `signer`. It
|
||||||
|
also refuses a manifest whose `signingPubKey` holds a DSA key or subkey, or any
|
||||||
|
secret key or subkey, since checking the self-signatures of a DSA key or the
|
||||||
|
numbers of a secret key can take hours when those numbers are very large.
|
||||||
|
|
||||||
|
A signature is good only if the primary key, and the subkey that made the
|
||||||
|
signature, were valid at the signature's creation time under the key validity
|
||||||
|
rules of RFC 9580. The key is judged by its current self-signatures and binding
|
||||||
|
signatures: for a version 4 key the newest self-signature on its primary user
|
||||||
|
ID, for a version 6 key its newest direct-key signature, and for a subkey its
|
||||||
|
newest binding signature with the signature embedded in it. So a signature made
|
||||||
|
before the newest of those is not good, even if an older one made the key valid
|
||||||
|
at that time. The reference implementation checks this by verifying the
|
||||||
|
signature a second time with its clock set to the signature's creation time. A
|
||||||
|
key that has expired since does not make the signature bad, because a manifest
|
||||||
|
outlives its key. A signature past its own expiry time is not good, whether or
|
||||||
|
not the key has expired too.
|
||||||
|
|
||||||
## Deterministic Serialization
|
## Deterministic Serialization
|
||||||
|
|
||||||
By default, manifests are generated deterministically:
|
The reference implementation writes the same inner message for the same files,
|
||||||
|
UUID and options, unless the options include `--include-timestamps`. It
|
||||||
|
compresses the same inner message to the same bytes:
|
||||||
|
|
||||||
- File entries are sorted by `path` in **lexicographic byte order**
|
- File entries are sorted by `path` in byte order, so `B.txt` comes before
|
||||||
- `createdAt` is omitted unless explicitly requested
|
`a.txt`.
|
||||||
- `mode` is `0000` unless explicitly requested
|
- Each message is encoded by the Go protobuf library with deterministic
|
||||||
|
marshaling, which writes fields in field-number order and leaves out the
|
||||||
|
fields that are not set. A field not declared `optional` counts as not set
|
||||||
|
when it holds its default value, zero or empty.
|
||||||
|
- Each entry holds only `path`, `size`, `hashes` with exactly one hash, `mtime`
|
||||||
|
and `mode`. The hash is the SHA-256 multihash of the file's content, except
|
||||||
|
that `freshen` reads only the files that are new or whose size, `mtime` or
|
||||||
|
`mode` changed, and for every other file carries the first hash of its
|
||||||
|
existing entry over unchanged. `mtime` is the file's modification time as the
|
||||||
|
file system gives it, to the nanosecond, and `mode` is `0000` unless `gen` or
|
||||||
|
`freshen` is given `--include-permissions`.
|
||||||
|
- `createdAt` is left out unless `gen` or `freshen` is given
|
||||||
|
`--include-timestamps`. Then it holds the time the run started building the
|
||||||
|
manifest, to the nanosecond, so it differs on every run.
|
||||||
|
- A time, in `mtime` or `createdAt`, is written as `seconds`, the whole seconds
|
||||||
|
since 1970-01-01 00:00:00 UTC rounded down, and `nanos`, the rest, from 0
|
||||||
|
to 999999999. So a time before 1970 has negative `seconds` and `nanos` of 0 or
|
||||||
|
more.
|
||||||
|
- The inner message is compressed into one zstd frame, with a checksum of its
|
||||||
|
content, by the encoder of the Go library `github.com/klauspost/compress` at
|
||||||
|
its `SpeedBestCompression` level and otherwise its default settings. That
|
||||||
|
encoder gives the same bytes for the same input only within one version of the
|
||||||
|
library. Another zstd encoder gives other bytes for the same inner message,
|
||||||
|
and so another `sha256`.
|
||||||
|
- `gen` and `freshen` choose a new random version 4 UUID on every run, except
|
||||||
|
that `gen` given `--seed` takes the first 16 bytes of the SHA-256 of the seed
|
||||||
|
string instead. Those bytes are not a version 4 UUID; readers accept any 16
|
||||||
|
bytes.
|
||||||
|
|
||||||
This ensures that two independent runs over the same directory tree produce
|
So two runs of `gen` over an unchanged tree write byte-identical files only when
|
||||||
byte-identical `.mf` files (assuming file contents and metadata have not
|
both are given the same `--seed`, neither is given `--include-timestamps`, and
|
||||||
changed).
|
neither signs the manifest: a signature records the time it was made, and the
|
||||||
|
OpenPGP library adds a random salt to each one.
|
||||||
|
|
||||||
|
## Test Vector
|
||||||
|
|
||||||
|
[`mfer/testdata/test-vector.mf`](../mfer/testdata/test-vector.mf) is a signed
|
||||||
|
manifest the reference implementation made once, with the UUID
|
||||||
|
`92197fd5-32bc-4090-8aa9-f86e5518b56c` and an Ed25519 key that the OpenPGP
|
||||||
|
library made for this file and nothing else; its secret key was not kept.
|
||||||
|
`TestFormatTestVector` in `mfer/deserialize_test.go` checks that the file's
|
||||||
|
SHA-256, its canonical string and `signer` are the ones shown here, and verifies
|
||||||
|
its signature.
|
||||||
|
|
||||||
|
A reader should load the file and accept its signature. A writer given the same
|
||||||
|
files and UUID should give the same inner message; its compressed bytes, and so
|
||||||
|
its `sha256` and signature, depend on its zstd encoder (see Deterministic
|
||||||
|
Serialization).
|
||||||
|
|
||||||
|
The manifest has no `createdAt` and lists two files, each with mode `0000` and
|
||||||
|
the modification time 2026-10-13 12:00:00.5 UTC (`seconds` 1791892800, `nanos`
|
||||||
|
500000000):
|
||||||
|
|
||||||
|
| `path` | `size` | Content |
|
||||||
|
| --------------- | ------ | ----------------------- |
|
||||||
|
| `dir/world.txt` | 6 | `world` and a line feed |
|
||||||
|
| `hello.txt` | 6 | `hello` and a line feed |
|
||||||
|
|
||||||
|
The inner message, 164 bytes before compression:
|
||||||
|
|
||||||
|
```
|
||||||
|
00000000 a0 06 01 aa 06 46 0a 0d 64 69 72 2f 77 6f 72 6c |.....F..dir/worl|
|
||||||
|
00000010 64 2e 74 78 74 10 06 1a 24 0a 22 12 20 e2 58 d2 |d.txt...$.". .X.|
|
||||||
|
00000020 48 fd a9 4c 63 75 36 07 f7 c4 49 4e e0 fc be 92 |H..Lcu6...IN....|
|
||||||
|
00000030 f1 a7 6b fd ac 79 5c 9d 84 10 1e b3 17 f2 12 0c |..k..y\.........|
|
||||||
|
00000040 08 c0 ba b8 d6 06 10 80 ca b5 ee 01 aa 06 42 0a |..............B.|
|
||||||
|
00000050 09 68 65 6c 6c 6f 2e 74 78 74 10 06 1a 24 0a 22 |.hello.txt...$."|
|
||||||
|
00000060 12 20 58 91 b5 b5 22 d5 df 08 6d 0f f0 b1 10 fb |. X..."...m.....|
|
||||||
|
00000070 d9 d2 1b b4 fc 71 63 af 34 d0 82 86 a2 e8 46 f6 |.....qc.4.....F.|
|
||||||
|
00000080 be 03 f2 12 0c 08 c0 ba b8 d6 06 10 80 ca b5 ee |................|
|
||||||
|
00000090 01 b2 06 10 92 19 7f d5 32 bc 40 90 8a a9 f8 6e |........2.@....n|
|
||||||
|
000000a0 55 18 b5 6c |U..l|
|
||||||
|
```
|
||||||
|
|
||||||
|
Its compressed form is hashed into `sha256`:
|
||||||
|
|
||||||
|
```
|
||||||
|
2353553205f036a88cefee3d0d9049ccd8e68bb24011a0b462b5867f1fcc6352
|
||||||
|
```
|
||||||
|
|
||||||
|
So the canonical string is:
|
||||||
|
|
||||||
|
```
|
||||||
|
ZNAVSRFG-92197fd532bc40908aa9f86e5518b56c-12202353553205f036a88cefee3d0d9049ccd8e68bb24011a0b462b5867f1fcc6352
|
||||||
|
```
|
||||||
|
|
||||||
|
`signer` is `5E962A37590D855B467D20294947A60319611E6F`, and `signature` holds:
|
||||||
|
|
||||||
|
```
|
||||||
|
-----BEGIN PGP SIGNATURE-----
|
||||||
|
|
||||||
|
wqsEABYIAF0FgmrOejEJEElHpgMZYR5vNRQAAAAAABwAEHNhbHRAbm90YXRpb25z
|
||||||
|
Lm9wZW5wZ3Bqcy5vcmddxfykLLTqmTD7G/jHGzY+FiEEXpYqN1kNhVtGfSApSUem
|
||||||
|
AxlhHm8AAH0hAQDI6oGxvk1sGqIcu3Abvs7DM/7EalxkND614N75FCg+ywEApP6L
|
||||||
|
oIvrZUgEw8DUb9JeK7TChOi6PUAHUFY75Fp6hAE=
|
||||||
|
=nfX5
|
||||||
|
-----END PGP SIGNATURE-----
|
||||||
|
```
|
||||||
|
|
||||||
|
The whole file, 1056 bytes, whose SHA-256 is
|
||||||
|
`5864187d671b2a55f8e015f858248f92c51956b549cf548b6bb54477c2407fd4`:
|
||||||
|
|
||||||
|
```
|
||||||
|
00000000 5a 4e 41 56 53 52 46 47 a8 06 01 b0 06 01 b8 06 |ZNAVSRFG........|
|
||||||
|
00000010 a4 01 c2 06 20 23 53 55 32 05 f0 36 a8 8c ef ee |.... #SU2..6....|
|
||||||
|
00000020 3d 0d 90 49 cc d8 e6 8b b2 40 11 a0 b4 62 b5 86 |=..I.....@...b..|
|
||||||
|
00000030 7f 1f cc 63 52 ca 06 10 92 19 7f d5 32 bc 40 90 |...cR.......2.@.|
|
||||||
|
00000040 8a a9 f8 6e 55 18 b5 6c ba 0c a1 01 28 b5 2f fd |...nU..l....(./.|
|
||||||
|
00000050 04 00 a5 04 00 94 08 a0 06 01 aa 06 46 0a 0d 64 |............F..d|
|
||||||
|
00000060 69 72 2f 77 6f 72 6c 64 2e 74 78 74 10 06 1a 24 |ir/world.txt...$|
|
||||||
|
00000070 0a 22 12 20 e2 58 d2 48 fd a9 4c 63 75 36 07 f7 |.". .X.H..Lcu6..|
|
||||||
|
00000080 c4 49 4e e0 fc be 92 f1 a7 6b fd ac 79 5c 9d 84 |.IN......k..y\..|
|
||||||
|
00000090 10 1e b3 17 f2 12 0c 08 c0 ba b8 d6 06 10 80 ca |................|
|
||||||
|
000000a0 b5 ee 01 aa 06 42 0a 09 68 65 6c 6c 6f 58 91 b5 |.....B..helloX..|
|
||||||
|
000000b0 b5 22 d5 df 08 6d 0f f0 b1 10 fb d9 d2 1b b4 fc |."...m..........|
|
||||||
|
000000c0 71 63 af 34 d0 82 86 a2 e8 46 f6 be 03 b2 06 10 |qc.4.....F......|
|
||||||
|
000000d0 92 19 7f d5 32 bc 40 90 8a a9 f8 6e 55 18 b5 6c |....2.@....nU..l|
|
||||||
|
000000e0 02 10 06 40 ba b7 10 ce 0c f8 c2 24 13 ca 0c ac |...@.......$....|
|
||||||
|
000000f0 02 2d 2d 2d 2d 2d 42 45 47 49 4e 20 50 47 50 20 |.-----BEGIN PGP |
|
||||||
|
00000100 53 49 47 4e 41 54 55 52 45 2d 2d 2d 2d 2d 0a 0a |SIGNATURE-----..|
|
||||||
|
00000110 77 71 73 45 41 42 59 49 41 46 30 46 67 6d 72 4f |wqsEABYIAF0FgmrO|
|
||||||
|
00000120 65 6a 45 4a 45 45 6c 48 70 67 4d 5a 59 52 35 76 |ejEJEElHpgMZYR5v|
|
||||||
|
00000130 4e 52 51 41 41 41 41 41 41 42 77 41 45 48 4e 68 |NRQAAAAAABwAEHNh|
|
||||||
|
00000140 62 48 52 41 62 6d 39 30 59 58 52 70 62 32 35 7a |bHRAbm90YXRpb25z|
|
||||||
|
00000150 0a 4c 6d 39 77 5a 57 35 77 5a 33 42 71 63 79 35 |.Lm9wZW5wZ3Bqcy5|
|
||||||
|
00000160 76 63 6d 64 64 78 66 79 6b 4c 4c 54 71 6d 54 44 |vcmddxfykLLTqmTD|
|
||||||
|
00000170 37 47 2f 6a 48 47 7a 59 2b 46 69 45 45 58 70 59 |7G/jHGzY+FiEEXpY|
|
||||||
|
00000180 71 4e 31 6b 4e 68 56 74 47 66 53 41 70 53 55 65 |qN1kNhVtGfSApSUe|
|
||||||
|
00000190 6d 0a 41 78 6c 68 48 6d 38 41 41 48 30 68 41 51 |m.AxlhHm8AAH0hAQ|
|
||||||
|
000001a0 44 49 36 6f 47 78 76 6b 31 73 47 71 49 63 75 33 |DI6oGxvk1sGqIcu3|
|
||||||
|
000001b0 41 62 76 73 37 44 4d 2f 37 45 61 6c 78 6b 4e 44 |Abvs7DM/7EalxkND|
|
||||||
|
000001c0 36 31 34 4e 37 35 46 43 67 2b 79 77 45 41 70 50 |614N75FCg+ywEApP|
|
||||||
|
000001d0 36 4c 0a 6f 49 76 72 5a 55 67 45 77 38 44 55 62 |6L.oIvrZUgEw8DUb|
|
||||||
|
000001e0 39 4a 65 4b 37 54 43 68 4f 69 36 50 55 41 48 55 |9JeK7TChOi6PUAHU|
|
||||||
|
000001f0 46 59 37 35 46 70 36 68 41 45 3d 0a 3d 6e 66 58 |FY75Fp6hAE=.=nfX|
|
||||||
|
00000200 35 0a 2d 2d 2d 2d 2d 45 4e 44 20 50 47 50 20 53 |5.-----END PGP S|
|
||||||
|
00000210 49 47 4e 41 54 55 52 45 2d 2d 2d 2d 2d d2 0c 28 |IGNATURE-----..(|
|
||||||
|
00000220 35 45 39 36 32 41 33 37 35 39 30 44 38 35 35 42 |5E962A37590D855B|
|
||||||
|
00000230 34 36 37 44 32 30 32 39 34 39 34 37 41 36 30 33 |467D20294947A603|
|
||||||
|
00000240 31 39 36 31 31 45 36 46 da 0c d4 03 2d 2d 2d 2d |19611E6F....----|
|
||||||
|
00000250 2d 42 45 47 49 4e 20 50 47 50 20 50 55 42 4c 49 |-BEGIN PGP PUBLI|
|
||||||
|
00000260 43 20 4b 45 59 20 42 4c 4f 43 4b 2d 2d 2d 2d 2d |C KEY BLOCK-----|
|
||||||
|
00000270 0a 0a 78 6a 4d 45 61 73 31 30 67 42 59 4a 4b 77 |..xjMEas10gBYJKw|
|
||||||
|
00000280 59 42 42 41 48 61 52 77 38 42 41 51 64 41 70 71 |YBBAHaRw8BAQdApq|
|
||||||
|
00000290 33 32 53 79 4f 72 4e 75 76 31 4d 71 53 55 66 45 |32SyOrNuv1MqSUfE|
|
||||||
|
000002a0 44 67 74 42 7a 6e 4b 59 32 39 38 2f 30 47 46 55 |DgtBznKY298/0GFU|
|
||||||
|
000002b0 76 6b 0a 6c 6f 42 71 35 69 6e 4e 4b 47 31 6d 5a |vk.loBq5inNKG1mZ|
|
||||||
|
000002c0 58 49 67 64 47 56 7a 64 43 42 32 5a 57 4e 30 62 |XIgdGVzdCB2ZWN0b|
|
||||||
|
000002d0 33 49 67 50 48 52 6c 63 33 51 74 64 6d 56 6a 64 |3IgPHRlc3QtdmVjd|
|
||||||
|
000002e0 47 39 79 51 47 31 6d 5a 58 49 75 64 47 56 7a 64 |G9yQG1mZXIudGVzd|
|
||||||
|
000002f0 44 37 43 0a 76 51 51 54 46 67 67 41 62 77 57 43 |D7C.vQQTFggAbwWC|
|
||||||
|
00000300 61 73 31 30 67 41 49 4c 42 77 6b 51 53 55 65 6d |as10gAILBwkQSUem|
|
||||||
|
00000310 41 78 6c 68 48 6d 38 31 46 41 41 41 41 41 41 41 |AxlhHm81FAAAAAAA|
|
||||||
|
00000320 48 41 41 51 63 32 46 73 64 45 42 75 62 33 52 68 |HAAQc2FsdEBub3Rh|
|
||||||
|
00000330 64 47 6c 76 0a 62 6e 4d 75 62 33 42 6c 62 6e 42 |dGlv.bnMub3BlbnB|
|
||||||
|
00000340 6e 63 47 70 7a 4c 6d 39 79 5a 30 79 62 6c 73 34 |ncGpzLm9yZ0ybls4|
|
||||||
|
00000350 71 53 43 6d 77 4a 64 45 49 45 6d 4c 72 54 55 45 |qSCmwJdEIEmLrTUE|
|
||||||
|
00000360 43 46 51 67 43 46 67 41 43 47 51 45 43 6d 77 4d |CFQgCFgACGQECmwM|
|
||||||
|
00000370 43 48 67 45 57 0a 49 51 52 65 6c 69 6f 33 57 51 |CHgEW.IQRelio3WQ|
|
||||||
|
00000380 32 46 57 30 5a 39 49 43 6c 4a 52 36 59 44 47 57 |2FW0Z9IClJR6YDGW|
|
||||||
|
00000390 45 65 62 77 41 41 31 67 73 41 2f 32 61 2f 50 58 |EebwAA1gsA/2a/PX|
|
||||||
|
000003a0 68 6c 44 49 4b 78 67 32 79 4e 30 4c 58 39 48 68 |hlDIKxg2yN0LX9Hh|
|
||||||
|
000003b0 49 44 43 54 7a 69 0a 49 32 43 53 71 44 74 2b 34 |IDCTzi.I2CSqDt+4|
|
||||||
|
000003c0 62 2f 73 7a 6d 59 64 41 51 43 66 2b 63 6a 6b 4f |b/szmYdAQCf+cjkO|
|
||||||
|
000003d0 71 6c 34 4c 47 5a 41 43 6c 2f 73 2f 2f 6d 31 71 |ql4LGZACl/s//m1q|
|
||||||
|
000003e0 62 74 6e 2f 4a 31 46 45 53 52 56 73 4e 68 65 2f |btn/J1FESRVsNhe/|
|
||||||
|
000003f0 76 6d 69 41 67 3d 3d 0a 3d 56 6b 4f 33 0a 2d 2d |vmiAg==.=VkO3.--|
|
||||||
|
00000400 2d 2d 2d 45 4e 44 20 50 47 50 20 50 55 42 4c 49 |---END PGP PUBLI|
|
||||||
|
00000410 43 20 4b 45 59 20 42 4c 4f 43 4b 2d 2d 2d 2d 2d |C KEY BLOCK-----|
|
||||||
|
```
|
||||||
|
|
||||||
## MIME Type
|
## MIME Type
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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/creack/pty v1.1.25-0.20260601142114-9246436fffe8
|
||||||
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
|
||||||
@@ -15,6 +17,7 @@ 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
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
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/creack/pty v1.1.25-0.20260601142114-9246436fffe8 h1:CY3gjC7naqYGLMiywvj3suPfa1i0p/QEr7o8ujxL/2M=
|
||||||
|
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
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=
|
||||||
|
|||||||
+32
-37
@@ -21,8 +21,8 @@ import (
|
|||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fingerprintHexLen is the length of a full GPG key fingerprint in hex
|
// fingerprintHexLen is the length in hex characters of the fingerprint of
|
||||||
// characters.
|
// an OpenPGP version 4 key, the only version mfer signs with.
|
||||||
const fingerprintHexLen = 40
|
const fingerprintHexLen = 40
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -108,7 +108,7 @@ func (mfa *CLIApp) fetchManifestToTemp(
|
|||||||
if tmpErr != nil {
|
if tmpErr != nil {
|
||||||
_ = rc.Close()
|
_ = rc.Close()
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to create temp file: %w", tmpErr)
|
return "", tmpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
@@ -126,17 +126,15 @@ func (mfa *CLIApp) fetchManifestToTemp(
|
|||||||
if cpErr != nil {
|
if cpErr != nil {
|
||||||
_ = mfa.Fs.Remove(tmpPath)
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to download manifest: %w", cpErr)
|
return "", fmt.Errorf("download manifest: %w", cpErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmpPath, nil
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||||
// against the manifest's embedded signing key.
|
// against the key that made the manifest's signature.
|
||||||
func verifyRequiredSigner(
|
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||||
ctx context.Context, chk *mfer.Checker, requiredSigner string,
|
|
||||||
) error {
|
|
||||||
// Validate fingerprint format: must be exactly 40 hex characters
|
// Validate fingerprint format: must be exactly 40 hex characters
|
||||||
if len(requiredSigner) != fingerprintHexLen {
|
if len(requiredSigner) != fingerprintHexLen {
|
||||||
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
||||||
@@ -144,7 +142,7 @@ func verifyRequiredSigner(
|
|||||||
|
|
||||||
_, err := hex.DecodeString(requiredSigner)
|
_, err := hex.DecodeString(requiredSigner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
|
return fmt.Errorf("invalid fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !chk.IsSigned() {
|
if !chk.IsSigned() {
|
||||||
@@ -152,22 +150,17 @@ func verifyRequiredSigner(
|
|||||||
errManifestNotSigned, requiredSigner)
|
errManifestNotSigned, requiredSigner)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract fingerprint from the embedded public key (not from the
|
// Loading the manifest checked that the signer is the fingerprint of
|
||||||
// signer field). This validates the key is importable and gets its
|
// the key that made the signature.
|
||||||
// actual fingerprint.
|
signer := string(chk.Signer())
|
||||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"failed to extract fingerprint from embedded signing key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare fingerprints - must be exact match (case-insensitive)
|
// Compare fingerprints - must be exact match (case-insensitive)
|
||||||
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
if !strings.EqualFold(signer, requiredSigner) {
|
||||||
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||||
embeddedFP, errSignerMismatch, requiredSigner)
|
signer, errSignerMismatch, requiredSigner)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
log.Infof("manifest signature verified (signer: %s)", signer)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -196,8 +189,8 @@ func reportCheckProgress(progress <-chan mfer.CheckStatus, wg *sync.WaitGroup) {
|
|||||||
log.ProgressDone()
|
log.ProgressDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
// countCheckFailures consumes check results, counting and logging
|
// countCheckFailures consumes check results, counting failures and logging
|
||||||
// failures, then closes done.
|
// each at error level so that --quiet still shows them, then closes done.
|
||||||
func countCheckFailures(
|
func countCheckFailures(
|
||||||
results <-chan mfer.Result, failures *int64, done chan<- struct{},
|
results <-chan mfer.Result, failures *int64, done chan<- struct{},
|
||||||
) {
|
) {
|
||||||
@@ -205,7 +198,7 @@ func countCheckFailures(
|
|||||||
if result.Status != mfer.StatusOK {
|
if result.Status != mfer.StatusOK {
|
||||||
*failures++
|
*failures++
|
||||||
|
|
||||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
log.Errorf("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
} else {
|
} else {
|
||||||
log.Verbosef("%s: %s", result.Status, result.Path)
|
log.Verbosef("%s: %s", result.Status, result.Path)
|
||||||
}
|
}
|
||||||
@@ -215,8 +208,8 @@ func countCheckFailures(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findExtraFiles reports files present on disk but absent from the
|
// findExtraFiles reports files present on disk but absent from the
|
||||||
// manifest, and anything the search cannot read: each is a failure under
|
// manifest, and anything the search cannot read: each is a failure, logged
|
||||||
// --no-extra-files, otherwise a warning.
|
// at error level, under --no-extra-files, otherwise a warning.
|
||||||
func findExtraFiles(
|
func findExtraFiles(
|
||||||
ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64,
|
ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64,
|
||||||
) error {
|
) error {
|
||||||
@@ -228,7 +221,7 @@ func findExtraFiles(
|
|||||||
if cmd.Bool("no-extra-files") {
|
if cmd.Bool("no-extra-files") {
|
||||||
*failures++
|
*failures++
|
||||||
|
|
||||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
log.Errorf("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
} else {
|
} else {
|
||||||
log.Warnf("%s: %s (%s)", result.Status, result.Path, result.Message)
|
log.Warnf("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
}
|
}
|
||||||
@@ -239,7 +232,7 @@ func findExtraFiles(
|
|||||||
|
|
||||||
err := chk.FindExtraFiles(ctx, extraResults)
|
err := chk.FindExtraFiles(ctx, extraResults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check for extra files: %w", err)
|
return fmt.Errorf("find extra files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
<-extraDone
|
<-extraDone
|
||||||
@@ -282,7 +275,7 @@ func runCheck(
|
|||||||
progressWg.Wait()
|
progressWg.Wait()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("check failed: %w", err)
|
return 0, fmt.Errorf("check files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for results processing to complete
|
// Wait for results processing to complete
|
||||||
@@ -303,14 +296,18 @@ func (mfa *CLIApp) checkManifestOperation(
|
|||||||
|
|
||||||
manifestPath, err := mfa.resolveManifestArg(cmd)
|
manifestPath, err := mfa.resolveManifestArg(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Done before a URL is swapped for the temp file it is downloaded to,
|
||||||
|
// whose directory is not the base.
|
||||||
|
basePath := resolveBasePath(cmd, manifestPath)
|
||||||
|
|
||||||
// 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 fmt.Errorf("check: %w", tmpErr)
|
return tmpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
||||||
@@ -318,26 +315,24 @@ 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("failed to load manifest: %w", err)
|
return fmt.Errorf("load manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check signature requirement
|
// Check signature requirement. An empty fingerprint is checked too,
|
||||||
requiredSigner := cmd.String(flagRequireSignature)
|
// and refused as one of the wrong length.
|
||||||
if requiredSigner != "" {
|
if cmd.IsSet(flagRequireSignature) {
|
||||||
err = verifyRequiredSigner(ctx, chk, requiredSigner)
|
err = verifyRequiredSigner(chk, cmd.String(flagRequireSignature))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ func noColorEnvSet() bool {
|
|||||||
// Use DefaultRunOptions for standard CLI execution, or construct manually for testing.
|
// Use DefaultRunOptions for standard CLI execution, or construct manually for testing.
|
||||||
type RunOptions struct {
|
type RunOptions struct {
|
||||||
Appname string // Application name displayed in help and version output
|
Appname string // Application name displayed in help and version output
|
||||||
Version string // Version string (typically set at build time)
|
Gitrev string // Revision stamped in at build time, or empty
|
||||||
Gitrev string // Git revision hash (typically set at build time)
|
|
||||||
Args []string // Command-line arguments (typically os.Args)
|
Args []string // Command-line arguments (typically os.Args)
|
||||||
Stdin io.Reader // Standard input stream
|
Stdin io.Reader // Standard input stream
|
||||||
Stdout io.Writer // Standard output stream
|
Stdout io.Writer // Standard output stream
|
||||||
@@ -36,10 +35,9 @@ type RunOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
|
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
|
||||||
func DefaultRunOptions(appname, version, gitrev string) *RunOptions {
|
func DefaultRunOptions(appname, gitrev string) *RunOptions {
|
||||||
return &RunOptions{
|
return &RunOptions{
|
||||||
Appname: appname,
|
Appname: appname,
|
||||||
Version: version,
|
|
||||||
Gitrev: gitrev,
|
Gitrev: gitrev,
|
||||||
Args: os.Args,
|
Args: os.Args,
|
||||||
Stdin: os.Stdin,
|
Stdin: os.Stdin,
|
||||||
@@ -50,15 +48,14 @@ func DefaultRunOptions(appname, version, gitrev string) *RunOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run creates and runs the CLI application with default options.
|
// Run creates and runs the CLI application with default options.
|
||||||
func Run(appname, version, gitrev string) int {
|
func Run(appname, gitrev string) int {
|
||||||
return RunWithOptions(DefaultRunOptions(appname, version, gitrev))
|
return RunWithOptions(DefaultRunOptions(appname, gitrev))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
|
||||||
gitrev: opts.Gitrev,
|
gitrev: opts.Gitrev,
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
maxManifestSize: mfer.MaxManifestSize,
|
maxManifestSize: mfer.MaxManifestSize,
|
||||||
|
|||||||
+695
-38
@@ -8,8 +8,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -26,6 +28,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
testApp = "mfer"
|
testApp = "mfer"
|
||||||
|
testGitrev = "abc123"
|
||||||
testDir = "/testdir"
|
testDir = "/testdir"
|
||||||
testFile1 = "/testdir/file1.txt"
|
testFile1 = "/testdir/file1.txt"
|
||||||
testMF = "/testdir/test.mf"
|
testMF = "/testdir/test.mf"
|
||||||
@@ -35,6 +38,8 @@ const (
|
|||||||
testFlagNoExtra = "--no-extra-files"
|
testFlagNoExtra = "--no-extra-files"
|
||||||
testFlagVersion = "--version"
|
testFlagVersion = "--version"
|
||||||
testFlagVerbose = "--verbose"
|
testFlagVerbose = "--verbose"
|
||||||
|
testFlagHelp = "--help"
|
||||||
|
testCmdHelp = "help"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errSimulatedWrite = errors.New("simulated write failure")
|
var errSimulatedWrite = errors.New("simulated write failure")
|
||||||
@@ -57,9 +62,10 @@ func runCLI(opts *RunOptions) int {
|
|||||||
// runCLIWithLevel is runCLI that also returns the log level the run left
|
// runCLIWithLevel is runCLI that also returns the log level the run left
|
||||||
// set, read while runMu still keeps other runs from changing it. Each run
|
// set, read while runMu still keeps other runs from changing it. Each run
|
||||||
// starts at the default level, as a new process does. Before releasing the
|
// starts at the default level, as a new process does. Before releasing the
|
||||||
// lock it points the process-global logger at io.Discard: other tests log
|
// lock it points the process-global logger at io.Discard: test helpers also
|
||||||
// outside the lock (manifest loads, scans), and those lines must not land in
|
// log under runMu outside any run (manifest loads, scans), and those lines
|
||||||
// this run's buffers once it has returned and its test is reading them.
|
// must not land in this run's buffers once it has returned and its test is
|
||||||
|
// reading them.
|
||||||
func runCLIWithLevel(opts *RunOptions) (int, log.Level) {
|
func runCLIWithLevel(opts *RunOptions) (int, log.Level) {
|
||||||
runMu.Lock()
|
runMu.Lock()
|
||||||
defer runMu.Unlock()
|
defer runMu.Unlock()
|
||||||
@@ -92,8 +98,7 @@ func TestBuild(t *testing.T) {
|
|||||||
func testOpts(args []string, fs afero.Fs) *RunOptions {
|
func testOpts(args []string, fs afero.Fs) *RunOptions {
|
||||||
return &RunOptions{
|
return &RunOptions{
|
||||||
Appname: testApp,
|
Appname: testApp,
|
||||||
Version: "1.0.0",
|
Gitrev: testGitrev,
|
||||||
Gitrev: "abc123",
|
|
||||||
Args: args,
|
Args: args,
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
Stdout: &bytes.Buffer{},
|
Stdout: &bytes.Buffer{},
|
||||||
@@ -135,10 +140,35 @@ func TestVersionCommand(t *testing.T) {
|
|||||||
exitCode := runCLI(opts)
|
exitCode := runCLI(opts)
|
||||||
|
|
||||||
assert.Equal(t, 0, exitCode)
|
assert.Equal(t, 0, exitCode)
|
||||||
|
assert.Equal(t, "mfer version "+testGitrev+"\n", testStdout(t, opts))
|
||||||
|
}
|
||||||
|
|
||||||
stdout := testStdout(t, opts)
|
// TestVersionFromBuildInfo covers a binary whose build stamped no Gitrev,
|
||||||
assert.Contains(t, stdout, mfer.Version)
|
// as `go install` builds it: mfer version prints the main module's version
|
||||||
assert.Contains(t, stdout, "abc123")
|
// from the build information instead.
|
||||||
|
func TestVersionFromBuildInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotEmpty(t, info.Main.Version)
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdVersion}, afero.NewMemMapFs())
|
||||||
|
opts.Gitrev = ""
|
||||||
|
|
||||||
|
require.Equal(t, 0, runCLI(opts))
|
||||||
|
assert.Equal(t, "mfer version "+info.Main.Version+"\n", testStdout(t, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModuleVersionEmpty covers build information that holds no module
|
||||||
|
// version, as in a build with GO111MODULE=off, and a binary with no build
|
||||||
|
// information at all. Both report "unknown": an empty version would print
|
||||||
|
// nothing after "mfer version" and drop the --version flag.
|
||||||
|
func TestModuleVersionEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, "unknown", moduleVersion(&debug.BuildInfo{}, true))
|
||||||
|
assert.Equal(t, "unknown", moduleVersion(nil, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVFlagCollision covers the -v/--verbose vs --version flag interaction
|
// TestVFlagCollision covers the -v/--verbose vs --version flag interaction
|
||||||
@@ -164,7 +194,7 @@ func TestVFlagCollision(t *testing.T) {
|
|||||||
exitCode := runCLI(opts)
|
exitCode := runCLI(opts)
|
||||||
|
|
||||||
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
||||||
assert.Contains(t, testStdout(t, opts), mfer.Version)
|
assert.Contains(t, testStdout(t, opts), testGitrev)
|
||||||
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
|
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -205,13 +235,13 @@ func TestVersionFlagAndCommandMatch(t *testing.T) {
|
|||||||
|
|
||||||
// TestRootVerbosityFlags asserts that -q and -v given before any subcommand
|
// TestRootVerbosityFlags asserts that -q and -v given before any subcommand
|
||||||
// name take effect: in the root itself, and in the fetch and export
|
// name take effect: in the root itself, and in the fetch and export
|
||||||
// subcommands (issue #64). It does not run in parallel: other tests log
|
// subcommands (issue #64).
|
||||||
// outside runMu (manifest loads, scans), and a line logged while one of these
|
|
||||||
// runs is in progress lands in its output.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // see above
|
|
||||||
func TestRootVerbosityFlags(t *testing.T) {
|
func TestRootVerbosityFlags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("quiet with no subcommand hides the banner", func(t *testing.T) {
|
t.Run("quiet with no subcommand hides the banner", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
loud := testOpts([]string{testApp}, afero.NewMemMapFs())
|
loud := testOpts([]string{testApp}, afero.NewMemMapFs())
|
||||||
require.Equal(t, 0, runCLI(loud))
|
require.Equal(t, 0, runCLI(loud))
|
||||||
assert.Contains(t, testStdout(t, loud), banner)
|
assert.Contains(t, testStdout(t, loud), banner)
|
||||||
@@ -223,6 +253,8 @@ func TestRootVerbosityFlags(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("quiet before fetch hides the banner", func(t *testing.T) {
|
t.Run("quiet before fetch hides the banner", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, "-q", cmdFetch}, afero.NewMemMapFs())
|
opts := testOpts([]string{testApp, "-q", cmdFetch}, afero.NewMemMapFs())
|
||||||
|
|
||||||
assert.Equal(t, 1, runCLI(opts))
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
@@ -231,6 +263,8 @@ func TestRootVerbosityFlags(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("verbose twice before fetch enables debug logging", func(t *testing.T) {
|
t.Run("verbose twice before fetch enables debug logging", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, "-v", "-v", cmdFetch}, afero.NewMemMapFs())
|
opts := testOpts([]string{testApp, "-v", "-v", cmdFetch}, afero.NewMemMapFs())
|
||||||
|
|
||||||
assert.Equal(t, 1, runCLI(opts))
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
@@ -238,6 +272,8 @@ func TestRootVerbosityFlags(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("quiet before export hides the manifest summary", func(t *testing.T) {
|
t.Run("quiet before export hides the manifest summary", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
manifest := buildTestManifest(t, map[string][]byte{"a.txt": []byte("a")})
|
manifest := buildTestManifest(t, map[string][]byte{"a.txt": []byte("a")})
|
||||||
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
|
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
|
||||||
@@ -320,7 +356,7 @@ func TestHelpCommand(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
opts := testOpts([]string{testApp, "--help"}, fs)
|
opts := testOpts([]string{testApp, testFlagHelp}, fs)
|
||||||
|
|
||||||
exitCode := runCLI(opts)
|
exitCode := runCLI(opts)
|
||||||
|
|
||||||
@@ -354,6 +390,44 @@ 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
|
||||||
@@ -613,6 +687,32 @@ func runCheckAfterRewrite(t *testing.T, rewritten, msg string) {
|
|||||||
assert.Equal(t, 1, exitCode, msg)
|
assert.Equal(t, 1, exitCode, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCheckRequireSignatureRefusesOtherSigningKey runs check
|
||||||
|
// --require-signature on a manifest signed by another key whose embedded
|
||||||
|
// public key block also holds the required key. check must refuse it.
|
||||||
|
func TestCheckRequireSignatureRefusesOtherSigningKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := []byte("signed file")
|
||||||
|
manifest, required := manifestSignedByAnotherKey(t,
|
||||||
|
map[string][]byte{testFileTxt: content})
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(testDir, testFileTxt), content, 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdCheck, "-q", testFlagBase, testDir,
|
||||||
|
"--" + flagRequireSignature, required, testManifest,
|
||||||
|
}, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.Contains(t, testStderr(t, opts),
|
||||||
|
"load manifest: "+
|
||||||
|
"embedded public key block must hold exactly one key, found 2")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -629,6 +729,33 @@ func TestCheckCommandWithSizeMismatch(t *testing.T) {
|
|||||||
"check should have failed for size mismatch")
|
"check should have failed for size mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCheckQuietShowsFailedFiles asserts that check -q prints nothing for a
|
||||||
|
// tree that passes and still names each file that fails: --quiet hides
|
||||||
|
// everything except errors, and a failed file is an error.
|
||||||
|
func TestCheckQuietShowsFailedFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
|
writeTestFile(t, fs, testFile1, "hello world")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "generate failed: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
args := []string{testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest}
|
||||||
|
|
||||||
|
opts = testOpts(args, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
assert.Empty(t, testStdout(t, opts))
|
||||||
|
assert.Empty(t, testStderr(t, opts))
|
||||||
|
|
||||||
|
writeTestFile(t, fs, testFile1, "HELLO WORLD")
|
||||||
|
|
||||||
|
opts = testOpts(args, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.Contains(t, testStderr(t, opts), "HASH_MISMATCH: file1.txt")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBannerOutput(t *testing.T) {
|
func TestBannerOutput(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -647,6 +774,9 @@ func TestBannerOutput(t *testing.T) {
|
|||||||
stdout := testStdout(t, opts)
|
stdout := testStdout(t, opts)
|
||||||
assert.Contains(t, stdout, "___")
|
assert.Contains(t, stdout, "___")
|
||||||
assert.Contains(t, stdout, "\\")
|
assert.Contains(t, stdout, "\\")
|
||||||
|
|
||||||
|
// The banner shows the version mfer version prints, and nothing after it.
|
||||||
|
assert.Contains(t, stdout, "mfer by @sneak: "+testGitrev+"\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownCommand(t *testing.T) {
|
func TestUnknownCommand(t *testing.T) {
|
||||||
@@ -679,13 +809,9 @@ func TestGenerateExcludesDotfilesByDefault(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
|
|
||||||
// Verify manifest only has 1 file (the non-dotfile)
|
// Verify manifest only has 1 file (the non-dotfile)
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
paths := manifestPaths(t, fs, testMF)
|
||||||
Path: testMF,
|
require.Len(t, paths, 1)
|
||||||
Fs: fs,
|
assert.Equal(t, "file1.txt", paths[0])
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, manifest.Files(), 1)
|
|
||||||
assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||||
@@ -706,12 +832,7 @@ func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
|||||||
require.Equal(t, 0, exitCode)
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
// Verify manifest has 2 files (including dotfile)
|
// Verify manifest has 2 files (including dotfile)
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
assert.Len(t, manifestPaths(t, fs, testMF), 2)
|
||||||
Path: testMF,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, manifest.Files(), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipleInputPaths(t *testing.T) {
|
func TestMultipleInputPaths(t *testing.T) {
|
||||||
@@ -861,8 +982,56 @@ func TestCheckWarnsAboutUnlistedFiles(t *testing.T) {
|
|||||||
assert.Equal(t, 1, exitCode, "stderr: %s", stderr)
|
assert.Equal(t, 1, exitCode, "stderr: %s", stderr)
|
||||||
assert.Contains(t, stderr, unlisted)
|
assert.Contains(t, stderr, unlisted)
|
||||||
|
|
||||||
exitCode, _ = check("-q", testFlagNoExtra)
|
exitCode, stderr = check("-q", testFlagNoExtra)
|
||||||
assert.Equal(t, 1, exitCode)
|
assert.Equal(t, 1, exitCode)
|
||||||
|
assert.Contains(t, stderr, unlisted)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckReportsUnlistedSymlinkToFileOnly makes a tree holding a symlink
|
||||||
|
// to a directory, one to a file and a broken one. gen lists the symlink to
|
||||||
|
// the file only with --follow-symlinks, and never lists the other two, so
|
||||||
|
// check --no-extra-files passes on the manifest gen --follow-symlinks writes
|
||||||
|
// and fails only on the symlink to the file on the one plain gen writes.
|
||||||
|
func TestCheckReportsUnlistedSymlinkToFileOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
genFlags []string
|
||||||
|
exitCode int
|
||||||
|
extras []string // what check reports as not in the manifest
|
||||||
|
}{
|
||||||
|
"gen --follow-symlinks": {[]string{"--follow-symlinks"}, 0, nil},
|
||||||
|
"plain gen": {nil, 1, []string{"filelink"}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestTree(t, fs, dir)
|
||||||
|
require.NoError(t, os.Symlink(testSubdir, filepath.Join(dir, "dirlink")))
|
||||||
|
require.NoError(t, os.Symlink(testFileTxt, filepath.Join(dir, "filelink")))
|
||||||
|
require.NoError(t,
|
||||||
|
os.Symlink("nowhere", filepath.Join(dir, "brokenlink")))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdGenerate, "-q"}, tc.genFlags, []string{dir},
|
||||||
|
), fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
opts = testOpts([]string{testApp, cmdCheck, testFlagNoExtra, dir}, fs)
|
||||||
|
exitCode := runCLI(opts)
|
||||||
|
stderr := testStderr(t, opts)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.exitCode, exitCode, "stderr: %s", stderr)
|
||||||
|
assert.Equal(t, len(tc.extras),
|
||||||
|
strings.Count(stderr, "not in manifest"), stderr)
|
||||||
|
|
||||||
|
for _, extra := range tc.extras {
|
||||||
|
assert.Contains(t, stderr, "EXTRA: "+extra)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,6 +1083,90 @@ 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
|
||||||
@@ -1047,6 +1300,11 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
|||||||
func manifestPaths(t *testing.T, fs afero.Fs, path string) []string {
|
func manifestPaths(t *testing.T, fs afero.Fs, path string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// Loading a manifest logs to the process-global logger, so serialize
|
||||||
|
// with the CLI runs.
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
Path: path,
|
Path: path,
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
@@ -1139,6 +1397,225 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testLink is the name of the symlink to a tree that the
|
||||||
|
// DirectoryNamedThroughSymlink tests make in a temp dir.
|
||||||
|
const testLink = "link"
|
||||||
|
|
||||||
|
// TestGenerateDirectoryNamedThroughSymlink runs gen on a directory named
|
||||||
|
// through a symlink, given as the argument or as the working directory: the
|
||||||
|
// manifest lists the files in the directory the symlink points to, and
|
||||||
|
// leaves out a symlink inside it, as gen does without --follow-symlinks.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestGenerateDirectoryNamedThroughSymlink(t *testing.T) {
|
||||||
|
// Paths are relative to a temp dir holding data and link, a symlink to
|
||||||
|
// data.
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
workDir string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
"argument": {".", []string{testLink}},
|
||||||
|
"working directory": {testLink, nil},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
data := filepath.Join(root, "data")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestTree(t, fs, data)
|
||||||
|
require.NoError(t,
|
||||||
|
os.Symlink(testFileTxt, filepath.Join(data, "alias.txt")))
|
||||||
|
require.NoError(t, os.Symlink(data, filepath.Join(root, testLink)))
|
||||||
|
|
||||||
|
// t.Chdir sets PWD to the path it is given, as a shell does, and
|
||||||
|
// os.Getwd returns PWD when it names the working directory.
|
||||||
|
t.Chdir(filepath.Join(root, tc.workDir))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdGenerate, "-q"}, tc.args,
|
||||||
|
), fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{testFileTxt, "sub/nested.txt"},
|
||||||
|
manifestPaths(t, fs, filepath.Join(data, defaultManifestName)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
@@ -1296,20 +1773,200 @@ func TestGenerateValidatesInputPaths(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFlagAfterArgumentIsArgument asserts that flags are read only before a
|
// generateTestDir writes testFile1 and runs gen on testDir, which writes
|
||||||
// command's first argument: after it, -v is a path, not the verbose flag.
|
// the manifest to testDir/index.mf.
|
||||||
func TestFlagAfterArgumentIsArgument(t *testing.T) {
|
func generateTestDir(t *testing.T, fs afero.Fs) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
writeTestFile(t, fs, testFile1, "content")
|
writeTestFile(t, fs, testFile1, "content")
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, cmdGenerate, testDir, "-v"}, fs)
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", testDir}, fs)
|
||||||
exitCode := runCLI(opts)
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t, 1, exitCode)
|
// TestCheckReadsOptionAfterPath runs check on an unsigned manifest with
|
||||||
assert.Contains(t, testStderr(t, opts), "path does not exist: -v")
|
// --require-signature after the directory. check must refuse the manifest.
|
||||||
|
func TestCheckReadsOptionAfterPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
generateTestDir(t, fs)
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdCheck, "-q", testDir, "--" + flagRequireSignature, msgFpA,
|
||||||
|
}, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.Contains(t, testStderr(t, opts), "manifest is not signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenReadsOptionAfterPath runs freshen with --include-permissions
|
||||||
|
// after the directory. freshen must record the file's mode.
|
||||||
|
func TestFreshenReadsOptionAfterPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
root, manifestPath := setupFreshenDir(t, fs,
|
||||||
|
map[string]string{testFileTxt: "a file"})
|
||||||
|
require.NoError(t, fs.Chmod(filepath.Join(root, testFileTxt), 0o640))
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdFreshen, "-q", root, "--" + flagIncludePermissions,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
files := manifestFiles(t, fs, manifestPath)
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
assert.Equal(t, uint32(0o640), files[0].GetMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchReadsOptionAfterURL runs fetch with --dest after the URL. The
|
||||||
|
// file and the manifest must land in that directory and nothing in the
|
||||||
|
// current one.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestFetchReadsOptionAfterURL(t *testing.T) {
|
||||||
|
content := []byte("fetched")
|
||||||
|
manifest := manifestOf(t, map[string][]byte{testFileTxt: content})
|
||||||
|
|
||||||
|
server := httptest.NewServer(fetchTestHandler(manifest,
|
||||||
|
map[string][]byte{testFileTxt: content}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cwd := chdirTemp(t)
|
||||||
|
dest := t.TempDir()
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdFetch, "-q", server.URL, "--" + flagDest, dest,
|
||||||
|
}, afero.NewOsFs())
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
map[string][]byte{testFileTxt: content, defaultManifestName: manifest},
|
||||||
|
filesUnder(t, dest))
|
||||||
|
assert.Empty(t, filesUnder(t, cwd), "fetch wrote outside --dest")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOneArgumentCommandsRefuseSecond runs each command that takes one
|
||||||
|
// argument with two. It must fail naming the second.
|
||||||
|
func TestOneArgumentCommandsRefuseSecond(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range []string{
|
||||||
|
cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch,
|
||||||
|
} {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
generateTestDir(t, fs)
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, command, testDir, "/second"}, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.Contains(t, testStderr(t, opts),
|
||||||
|
`unexpected argument "/second"`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateReadsPathAfterDoubleDash runs gen -- -x where -x is a
|
||||||
|
// directory. -x must be read as the path to list, not as an option.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestGenerateReadsPathAfterDoubleDash(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
require.NoError(t, fs.Mkdir("-x", 0o755))
|
||||||
|
writeTestFile(t, fs, filepath.Join("-x", testFileTxt), "content")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "--", "-x"}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
files := manifestFiles(t, fs, filepath.Join("-x", defaultManifestName))
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
assert.Equal(t, testFileTxt, files[0].GetPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPathNamedHelp runs gen, then check with --require-signature, on a
|
||||||
|
// directory named help and on one named h, the two names of urfave/cli's
|
||||||
|
// help command. gen must write the manifest into the directory, and check
|
||||||
|
// must refuse it as unsigned.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestPathNamedHelp(t *testing.T) {
|
||||||
|
for _, name := range []string{testCmdHelp, "h"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
require.NoError(t, fs.Mkdir(name, 0o755))
|
||||||
|
writeTestFile(t, fs, filepath.Join(name, testFileTxt), "content")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", name}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
files := manifestFiles(t, fs, filepath.Join(name, defaultManifestName))
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
opts = testOpts([]string{
|
||||||
|
testApp, cmdCheck, "-q", name, "--" + flagRequireSignature, msgFpA,
|
||||||
|
}, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.Contains(t, testStderr(t, opts), "manifest is not signed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandsReadHelpAsArgument runs each command that takes a path or URL
|
||||||
|
// on help and on h where nothing by that name exists. Each must fail on the
|
||||||
|
// missing path or bad URL instead of printing its help.
|
||||||
|
func TestCommandsReadHelpAsArgument(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range []string{
|
||||||
|
cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch,
|
||||||
|
} {
|
||||||
|
for _, name := range []string{testCmdHelp, "h"} {
|
||||||
|
t.Run(command+" "+name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, command, name}, afero.NewMemMapFs())
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.NotContains(t, testStdout(t, opts), "USAGE:")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHelpBesideArgument runs each command that takes a path or URL with
|
||||||
|
// --help or -h before and after a directory, and runs help on that command.
|
||||||
|
// Each must print what the command's own --help prints.
|
||||||
|
func TestHelpBesideArgument(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range []string{
|
||||||
|
cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch,
|
||||||
|
} {
|
||||||
|
want := testOpts([]string{testApp, command, testFlagHelp}, afero.NewMemMapFs())
|
||||||
|
require.Equal(t, 0, runCLI(want))
|
||||||
|
require.Contains(t, testStdout(t, want), testApp+" "+command)
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{command, testDir, testFlagHelp},
|
||||||
|
{command, testFlagHelp, testDir},
|
||||||
|
{command, testDir, "-h"},
|
||||||
|
{testCmdHelp, command},
|
||||||
|
} {
|
||||||
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat([]string{testApp}, args), afero.NewMemMapFs())
|
||||||
|
assert.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
assert.Equal(t, testStdout(t, want), testStdout(t, opts))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||||
|
|||||||
+255
-49
@@ -4,17 +4,23 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"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"
|
||||||
urfcli "github.com/urfave/cli/v3"
|
urfcli "github.com/urfave/cli/v3"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +54,11 @@ func runLocked(fn func() error) error {
|
|||||||
func unsignedChecker(t *testing.T) *mfer.Checker {
|
func unsignedChecker(t *testing.T) *mfer.Checker {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// The scan and the manifest load log to the process-global logger, so
|
||||||
|
// serialize with the CLI runs.
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
||||||
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||||
@@ -86,8 +97,7 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|||||||
t.Run("invalid fingerprint length", func(t *testing.T) {
|
t.Run("invalid fingerprint length", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := verifyRequiredSigner(context.Background(),
|
err := verifyRequiredSigner(unsignedChecker(t), "12345678")
|
||||||
unsignedChecker(t), "12345678")
|
|
||||||
require.ErrorIs(t, err, errInvalidFingerprint)
|
require.ErrorIs(t, err, errInvalidFingerprint)
|
||||||
assert.EqualError(t, err,
|
assert.EqualError(t, err,
|
||||||
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
||||||
@@ -96,8 +106,7 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|||||||
t.Run("manifest not signed", func(t *testing.T) {
|
t.Run("manifest not signed", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := verifyRequiredSigner(context.Background(),
|
err := verifyRequiredSigner(unsignedChecker(t), msgFpA)
|
||||||
unsignedChecker(t), msgFpA)
|
|
||||||
require.ErrorIs(t, err, errManifestNotSigned)
|
require.ErrorIs(t, err, errManifestNotSigned)
|
||||||
assert.EqualError(t, err,
|
assert.EqualError(t, err,
|
||||||
"manifest is not signed, but signature from "+msgFpA+" is required")
|
"manifest is not signed, but signature from "+msgFpA+" is required")
|
||||||
@@ -105,63 +114,65 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
|
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
|
||||||
// manifest. The embedded fingerprint is whatever the generated key produced,
|
// manifest. The signing key's fingerprint is whatever the generated key
|
||||||
// so it is read back from the checker and substituted into the expected
|
// produced, so it is read back from the checker and substituted into the
|
||||||
// string; the required signer is a fixed value that cannot match it. Requires
|
// expected string; the required signer is a fixed value that cannot match
|
||||||
// gpg and is skipped where it is absent, as the other signing tests are.
|
// it.
|
||||||
//
|
|
||||||
//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")}))
|
||||||
|
|
||||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(context.Background())
|
err := verifyRequiredSigner(chk, msgFpB)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = verifyRequiredSigner(context.Background(), chk, msgFpB)
|
|
||||||
require.ErrorIs(t, err, errSignerMismatch)
|
require.ErrorIs(t, err, errSignerMismatch)
|
||||||
assert.EqualError(t, err,
|
assert.EqualError(t, err,
|
||||||
"embedded signing key fingerprint "+embeddedFP+
|
"embedded signing key fingerprint "+string(chk.Signer())+
|
||||||
" does not match required "+msgFpB)
|
" does not match required "+msgFpB)
|
||||||
}
|
}
|
||||||
|
|
||||||
// signedManifest returns a manifest of files signed by a throwaway GPG key
|
// testSecretKey returns a new OpenPGP key with its secret key, armored, as
|
||||||
// generated in a temporary GNUPGHOME, which it leaves set for the rest of
|
// gpg --export-secret-keys --armor writes it, and the key's fingerprint.
|
||||||
// the test.
|
// The key is protected by passphrase unless that is nil. config sets how
|
||||||
|
// the key is made; without one it is an Ed25519 key, which is quick to
|
||||||
|
// make.
|
||||||
|
func testSecretKey(
|
||||||
|
t *testing.T, passphrase []byte, config *packet.Config,
|
||||||
|
) ([]byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
config = &packet.Config{Algorithm: packet.PubKeyAlgoEdDSA}
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test", config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if passphrase != nil {
|
||||||
|
require.NoError(t, key.EncryptPrivateKeys(passphrase, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, key.SerializePrivateWithoutSigning(w, nil))
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
return buf.Bytes(), strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedManifest returns a manifest of files signed by a new OpenPGP key.
|
||||||
func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
_, err := exec.LookPath("gpg")
|
secretKey, _ := testSecretKey(t, nil, nil)
|
||||||
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{KeyID: mfer.GPGKeyID("test@mfer.test")})
|
b.SetSigningOptions(&mfer.SigningOptions{SecretKey: secretKey})
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -177,6 +188,11 @@ func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
|||||||
func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// Loading a manifest logs to the process-global logger, so serialize
|
||||||
|
// with the CLI runs.
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644))
|
||||||
|
|
||||||
@@ -191,6 +207,52 @@ func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
|||||||
return chk
|
return chk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// manifestSignedByAnotherKey returns a manifest of files and the
|
||||||
|
// fingerprint of a new key, the required key, that did not sign it.
|
||||||
|
// The manifest is signed by a second new key; its embedded public key
|
||||||
|
// block holds the required key followed by the second key, and its signer
|
||||||
|
// field names the required key.
|
||||||
|
func manifestSignedByAnotherKey(
|
||||||
|
t *testing.T, files map[string][]byte,
|
||||||
|
) ([]byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
required := new(mfer.MFFileOuter)
|
||||||
|
require.NoError(t, proto.Unmarshal(
|
||||||
|
signedManifest(t, files)[len(mfer.MAGIC):], required))
|
||||||
|
|
||||||
|
outer := new(mfer.MFFileOuter)
|
||||||
|
require.NoError(t, proto.Unmarshal(
|
||||||
|
signedManifest(t, files)[len(mfer.MAGIC):], outer))
|
||||||
|
|
||||||
|
// One armored block holding both keys, as gpg --export --armor writes
|
||||||
|
// two keys.
|
||||||
|
var block bytes.Buffer
|
||||||
|
|
||||||
|
w, err := armor.Encode(&block, openpgp.PublicKeyType, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, key := range [][]byte{
|
||||||
|
required.GetSigningPubKey(), outer.GetSigningPubKey(),
|
||||||
|
} {
|
||||||
|
decoded, err := armor.Decode(bytes.NewReader(key))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(w, decoded.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
outer.SigningPubKey = block.Bytes()
|
||||||
|
outer.Signer = required.GetSigner()
|
||||||
|
|
||||||
|
data, err := proto.Marshal(outer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return append([]byte(mfer.MAGIC), data...), string(required.GetSigner())
|
||||||
|
}
|
||||||
|
|
||||||
func TestPathDoesNotExistMessage(t *testing.T) {
|
func TestPathDoesNotExistMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -277,7 +339,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,
|
||||||
"failed to fetch "+server.URL+"/foo.mf: HTTP 404")
|
"download manifest "+server.URL+"/foo.mf: unexpected HTTP status 404")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchManifestHTTPStatusMessage(t *testing.T) {
|
func TestFetchManifestHTTPStatusMessage(t *testing.T) {
|
||||||
@@ -299,7 +361,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, "failed to fetch manifest: HTTP 404")
|
assert.EqualError(t, err, "download manifest: unexpected HTTP status 404")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
||||||
@@ -317,7 +379,151 @@ 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, "HTTP 500")
|
assert.EqualError(t, err, "unexpected HTTP status 500")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckCorruptManifestMessage runs check on a file that is not a
|
||||||
|
// manifest.
|
||||||
|
func TestCheckCorruptManifestMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
|
||||||
|
|
||||||
|
mfa := &CLIApp{Fs: fs}
|
||||||
|
cmd := mfa.checkCommand()
|
||||||
|
cmd.Action = mfa.checkManifestOperation
|
||||||
|
|
||||||
|
// checkManifestOperation logs to the process-global logger.
|
||||||
|
err := runLocked(func() error {
|
||||||
|
return cmd.Run(context.Background(), []string{cmdCheck, "/bad.mf"})
|
||||||
|
})
|
||||||
|
assert.EqualError(t, err, "load manifest: invalid file format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListMissingManifestMessage runs list on a manifest file that does not
|
||||||
|
// exist.
|
||||||
|
func TestListMissingManifestMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
||||||
|
cmd := mfa.listCommand()
|
||||||
|
|
||||||
|
// listManifestOperation sets the process-global log level.
|
||||||
|
err := runLocked(func() error {
|
||||||
|
return cmd.Run(context.Background(), []string{cmdList, "/nope.mf"})
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
assert.EqualError(t, err, "open /nope.mf: file does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchHashMismatchMessage runs fetch against a server that sends a
|
||||||
|
// listed file with other content of the same size.
|
||||||
|
func TestFetchHashMismatchMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifest := builtManifest(t, map[string][]byte{testFileTxt: []byte("listed")})
|
||||||
|
|
||||||
|
server := httptest.NewServer(fetchTestHandler(manifest,
|
||||||
|
map[string][]byte{testFileTxt: []byte("served")}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mfa := &CLIApp{Fs: afero.NewMemMapFs(), maxManifestSize: mfer.MaxManifestSize}
|
||||||
|
cmd := mfa.fetchCommand()
|
||||||
|
cmd.Action = mfa.fetchManifestOperation
|
||||||
|
|
||||||
|
// fetchManifestOperation logs to the process-global logger.
|
||||||
|
err := runLocked(func() error {
|
||||||
|
return cmd.Run(context.Background(),
|
||||||
|
[]string{cmdFetch, "--" + flagDest, t.TempDir(), server.URL})
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, errHashMismatch)
|
||||||
|
assert.EqualError(t, err, "download "+testFileTxt+": hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenBackslashPathMessage runs freshen on a tree that has gained a
|
||||||
|
// file whose name holds a backslash, which a manifest path may not contain.
|
||||||
|
func TestFreshenBackslashPathMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
root, manifestPath := setupFreshenDir(t, fs,
|
||||||
|
map[string]string{testFileTxt: "content"})
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, `a\b.txt`), "new")
|
||||||
|
|
||||||
|
mfa := &CLIApp{Fs: fs}
|
||||||
|
cmd := mfa.freshenCommand()
|
||||||
|
cmd.Action = mfa.freshenManifestOperation
|
||||||
|
|
||||||
|
// freshenManifestOperation logs to the process-global logger.
|
||||||
|
err := runLocked(func() error {
|
||||||
|
return cmd.Run(context.Background(),
|
||||||
|
[]string{cmdFreshen, testFlagBase, root, manifestPath})
|
||||||
|
})
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
`path "a\\b.txt" contains backslash; use forward slashes only`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenReadErrorMessage has freshen hash a directory as though it
|
||||||
|
// were a file, so reading it fails.
|
||||||
|
func TestFreshenReadErrorMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(root, "sub"), 0o750))
|
||||||
|
|
||||||
|
hasher := &freshenHasher{
|
||||||
|
fs: afero.NewOsFs(),
|
||||||
|
absBase: root,
|
||||||
|
builder: mfer.NewBuilder(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hasher.processEntry(&freshenEntry{path: "sub", needsHash: true})
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
"read "+filepath.Join(root, "sub")+": is a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenSizeChangedMessage has freshen hash a file that has grown or
|
||||||
|
// shrunk since the scan recorded its size.
|
||||||
|
func TestFreshenSizeChangedMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeTestFile(t, fs, "/d/log.txt", "hello")
|
||||||
|
|
||||||
|
t.Run("grown", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hasher := &freshenHasher{
|
||||||
|
fs: fs,
|
||||||
|
absBase: "/d",
|
||||||
|
builder: mfer.NewBuilder(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hasher.processEntry(
|
||||||
|
&freshenEntry{path: "log.txt", size: 3, needsHash: true})
|
||||||
|
require.ErrorIs(t, err, errSizeMismatch)
|
||||||
|
assert.EqualError(t, err, `size mismatch for "log.txt": `+
|
||||||
|
`3 bytes when scanned but 5 bytes when read; `+
|
||||||
|
`the file changed while freshen ran`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("shrunk", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hasher := &freshenHasher{
|
||||||
|
fs: fs,
|
||||||
|
absBase: "/d",
|
||||||
|
builder: mfer.NewBuilder(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hasher.processEntry(
|
||||||
|
&freshenEntry{path: "log.txt", size: 8, needsHash: true})
|
||||||
|
require.ErrorIs(t, err, errSizeMismatch)
|
||||||
|
assert.EqualError(t, err, `size mismatch for "log.txt": `+
|
||||||
|
`8 bytes when scanned but 5 bytes when read; `+
|
||||||
|
`the file changed while freshen ran`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestURLRequiredMessage(t *testing.T) {
|
func TestURLRequiredMessage(t *testing.T) {
|
||||||
|
|||||||
@@ -26,20 +26,19 @@ func (mfa *CLIApp) exportManifestOperation(
|
|||||||
) error {
|
) error {
|
||||||
pathOrURL, err := mfa.resolveManifestArg(cmd)
|
pathOrURL, err := mfa.resolveManifestArg(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export: %w", err)
|
return 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("export: failed to parse manifest: %w", err)
|
return fmt.Errorf("parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files := manifest.Files()
|
files := manifest.Files()
|
||||||
@@ -76,7 +75,7 @@ func (mfa *CLIApp) exportManifestOperation(
|
|||||||
|
|
||||||
err = enc.Encode(entries)
|
err = enc.Encode(entries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export: failed to encode JSON: %w", err)
|
return fmt.Errorf("encode JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ const testCmdExport = "export"
|
|||||||
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// The scan logs to the process-global logger, so serialize with the
|
||||||
|
// CLI runs.
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
sourceFs := afero.NewMemMapFs()
|
sourceFs := afero.NewMemMapFs()
|
||||||
for path, content := range files {
|
for path, content := range files {
|
||||||
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
||||||
|
|||||||
+62
-48
@@ -86,7 +86,8 @@ var (
|
|||||||
// symlink that already exists in the target directory.
|
// symlink that already exists in the target directory.
|
||||||
errSymlinkInPath = errors.New("symlink in path not allowed")
|
errSymlinkInPath = errors.New("symlink in path not allowed")
|
||||||
// errSizeMismatch indicates a downloaded file with an unexpected
|
// errSizeMismatch indicates a downloaded file with an unexpected
|
||||||
// size.
|
// size, or a file freshen read at a size other than the one its scan
|
||||||
|
// recorded.
|
||||||
errSizeMismatch = errors.New("size mismatch")
|
errSizeMismatch = errors.New("size mismatch")
|
||||||
// errHashMismatch indicates a downloaded file whose hash matches no
|
// errHashMismatch indicates a downloaded file whose hash matches no
|
||||||
// manifest hash.
|
// manifest hash.
|
||||||
@@ -125,8 +126,9 @@ type retryingClient struct {
|
|||||||
// to a limit that doubles each time, unless the server's Retry-After
|
// to a limit that doubles each time, unless the server's Retry-After
|
||||||
// header says how long to wait. Any other failure, an error from use
|
// header says how long to wait. Any other failure, an error from use
|
||||||
// included, is returned at once and unwrapped; a non-OK status is
|
// included, is returned at once and unwrapped; a non-OK status is
|
||||||
// returned as errHTTPStatus. use must start over each time it is called,
|
// returned as errHTTPStatus. If ctx ends before a retry, the error wraps
|
||||||
// since a retry calls it again with a new response.
|
// both ctx's error and the last try's error. use must start over each
|
||||||
|
// time it is called, since a retry calls it again with a new response.
|
||||||
func (c retryingClient) get(
|
func (c retryingClient) get(
|
||||||
ctx context.Context, rawURL string, use func(*http.Response) error,
|
ctx context.Context, rawURL string, use func(*http.Response) error,
|
||||||
) error {
|
) error {
|
||||||
@@ -168,15 +170,19 @@ func (c retryingClient) get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !retry || attempt == fetchAttempts || ctx.Err() != nil {
|
if !retry || attempt == fetchAttempts {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ctx.Err(), err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Warnf("%s: %s, retrying in %s", rawURL, err, wait.Round(time.Millisecond))
|
log.Warnf("%s: %s, retrying in %s", rawURL, err, wait.Round(time.Millisecond))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return fmt.Errorf("%w: %w", ctx.Err(), err)
|
||||||
case <-time.After(wait):
|
case <-time.After(wait):
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +245,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("fetch: invalid manifest URL: %w", err)
|
return nil, fmt.Errorf("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 +274,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 path in manifest: %w", err)
|
return 0, 0, fmt.Errorf("invalid file entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if alreadyPresent(dest, localPath, f) {
|
if alreadyPresent(dest, localPath, f) {
|
||||||
@@ -284,7 +290,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("failed to download %s: %w", f.GetPath(), err)
|
return 0, 0, fmt.Errorf("download %s: %w", f.GetPath(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloaded++
|
downloaded++
|
||||||
@@ -376,7 +382,7 @@ func (mfa *CLIApp) fetchManifestOperation(
|
|||||||
|
|
||||||
err = os.MkdirAll(dest, dirPerms)
|
err = os.MkdirAll(dest, dirPerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create destination directory %s: %w", dest, err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create progress channel and start progress reporter goroutine
|
// Create progress channel and start progress reporter goroutine
|
||||||
@@ -403,7 +409,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("failed to save manifest: %w", err)
|
return fmt.Errorf("save manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print summary
|
// Print summary
|
||||||
@@ -429,6 +435,14 @@ func (mfa *CLIApp) fetchManifestOperation(
|
|||||||
func (mfa *CLIApp) fetchManifest(
|
func (mfa *CLIApp) 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) {
|
||||||
|
// An empty fingerprint is refused before anything is requested, with
|
||||||
|
// the message check gives for it; any other is checked against the
|
||||||
|
// manifest once it is downloaded.
|
||||||
|
requiredSigner := cmd.String(flagRequireSignature)
|
||||||
|
if cmd.IsSet(flagRequireSignature) && requiredSigner == "" {
|
||||||
|
return nil, nil, fmt.Errorf("%w, got 0", errInvalidFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("fetching manifest from %s", manifestURL)
|
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
|
||||||
@@ -446,24 +460,22 @@ func (mfa *CLIApp) fetchManifest(
|
|||||||
return readErr
|
return readErr
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to fetch manifest: %w", err)
|
return nil, nil, fmt.Errorf("download manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if int64(len(manifestData)) > mfa.maxManifestSize {
|
if int64(len(manifestData)) > mfa.maxManifestSize {
|
||||||
return nil, nil, fmt.Errorf("failed to fetch manifest: %w of %d bytes",
|
return nil, nil, fmt.Errorf("download manifest: %w of %d bytes",
|
||||||
errManifestTooLarge, mfa.maxManifestSize)
|
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("failed to parse manifest: %w", err)
|
return nil, nil, fmt.Errorf("parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
requiredSigner := cmd.String(flagRequireSignature)
|
|
||||||
if requiredSigner != "" {
|
if requiredSigner != "" {
|
||||||
err = verifyFetchedSigner(ctx, manifestData, requiredSigner)
|
err = verifyFetchedSigner(manifestData, requiredSigner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -545,9 +557,7 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
|
|||||||
// exactly as check does. verifyRequiredSigner takes a Checker, which loads
|
// exactly as check does. verifyRequiredSigner takes a Checker, which loads
|
||||||
// its manifest from a file, so the manifest is handed to it as a file in
|
// its manifest from a file, so the manifest is handed to it as a file in
|
||||||
// memory.
|
// memory.
|
||||||
func verifyFetchedSigner(
|
func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
|
||||||
ctx context.Context, manifestData []byte, requiredSigner string,
|
|
||||||
) error {
|
|
||||||
memFs := afero.NewMemMapFs()
|
memFs := afero.NewMemMapFs()
|
||||||
manifestPath := "/" + defaultManifestName
|
manifestPath := "/" + defaultManifestName
|
||||||
|
|
||||||
@@ -556,17 +566,16 @@ func verifyFetchedSigner(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
//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: "/",
|
||||||
Fs: memFs,
|
Fs: memFs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load manifest: %w", err)
|
return fmt.Errorf("load manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return verifyRequiredSigner(ctx, chk, requiredSigner)
|
return verifyRequiredSigner(chk, requiredSigner)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveManifest writes the fetched manifest into dest under the default
|
// saveManifest writes the fetched manifest into dest under the default
|
||||||
@@ -655,7 +664,7 @@ func checkNoSymlinks(dest, p string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check %s for a symlink: %w", current, err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
@@ -683,21 +692,26 @@ func resolveManifestURL(inputURL string) (string, error) {
|
|||||||
return parsed.JoinPath(defaultManifestName).String(), nil
|
return parsed.JoinPath(defaultManifestName).String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// progressWriter wraps an io.Writer and reports progress to a channel.
|
// progressWriter wraps an io.Writer and reports progress to a channel,
|
||||||
|
// at most once per second and once more when the last byte is written.
|
||||||
type progressWriter struct {
|
type progressWriter struct {
|
||||||
w io.Writer
|
w io.Writer
|
||||||
path string
|
path string
|
||||||
total int64
|
total int64
|
||||||
written int64
|
written int64
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
progress chan<- DownloadProgress
|
lastProgress time.Time
|
||||||
|
progress chan<- DownloadProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.w.Write(p)
|
n, err := pw.w.Write(p)
|
||||||
|
|
||||||
pw.written += int64(n)
|
pw.written += int64(n)
|
||||||
if pw.progress != nil {
|
|
||||||
|
isLast := pw.written == pw.total
|
||||||
|
if pw.progress != nil &&
|
||||||
|
(isLast || time.Since(pw.lastProgress) >= time.Second) {
|
||||||
var (
|
var (
|
||||||
bytesPerSec float64
|
bytesPerSec float64
|
||||||
eta time.Duration
|
eta time.Duration
|
||||||
@@ -719,6 +733,8 @@ func (pw *progressWriter) Write(p []byte) (int, error) {
|
|||||||
BytesPerSec: bytesPerSec,
|
BytesPerSec: bytesPerSec,
|
||||||
ETA: eta,
|
ETA: eta,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
pw.lastProgress = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
return n, err
|
return n, err
|
||||||
@@ -772,7 +788,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("failed to encode hash: %w", err)
|
return fmt.Errorf("encode hash: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, hash := range entry.GetHashes() {
|
for _, hash := range entry.GetHashes() {
|
||||||
@@ -799,7 +815,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 path: %w", err)
|
return fmt.Errorf("invalid file entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create parent directories if needed
|
// Create parent directories if needed
|
||||||
@@ -814,7 +830,7 @@ func downloadFile(
|
|||||||
|
|
||||||
err = os.MkdirAll(dir, dirPerms)
|
err = os.MkdirAll(dir, dirPerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,7 +864,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, fmt.Errorf("failed to create temp file: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -861,12 +877,7 @@ func moveIntoPlace(dest, tmpPath, localPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.Rename(filepath.Join(dest, tmpPath), filepath.Join(dest, localPath))
|
return 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,
|
||||||
@@ -901,20 +912,23 @@ func saveResponse(
|
|||||||
_ = out.Close()
|
_ = out.Close()
|
||||||
_ = os.Remove(filepath.Join(dest, tmpPath))
|
_ = os.Remove(filepath.Join(dest, tmpPath))
|
||||||
|
|
||||||
return fmt.Errorf("failed to set mode: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up hash computation
|
// Set up hash computation
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
|
||||||
// Create progress-reporting writer that also computes hash
|
// Create progress-reporting writer that also computes hash. The first
|
||||||
|
// update waits a second, as gen's and check's do.
|
||||||
|
start := time.Now()
|
||||||
pw := &progressWriter{
|
pw := &progressWriter{
|
||||||
w: io.MultiWriter(out, h),
|
w: io.MultiWriter(out, h),
|
||||||
path: localPath,
|
path: localPath,
|
||||||
total: totalBytes,
|
total: totalBytes,
|
||||||
startTime: time.Now(),
|
startTime: start,
|
||||||
progress: progress,
|
lastProgress: start,
|
||||||
|
progress: progress,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content while hashing and reporting progress. One byte past
|
// Copy content while hashing and reporting progress. One byte past
|
||||||
|
|||||||
+136
-51
@@ -171,6 +171,11 @@ func TestResolveManifestURL(t *testing.T) {
|
|||||||
func scanToManifest(t *testing.T, sourceFs afero.Fs) []byte {
|
func scanToManifest(t *testing.T, sourceFs afero.Fs) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// The scan logs to the process-global logger, so serialize with the
|
||||||
|
// CLI runs.
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: sourceFs})
|
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: sourceFs})
|
||||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
@@ -314,8 +319,11 @@ func TestFetchFromHTTP(t *testing.T) {
|
|||||||
// Change to a fresh destination directory for the test
|
// Change to a fresh destination directory for the test
|
||||||
destDir := chdirTemp(t)
|
destDir := chdirTemp(t)
|
||||||
|
|
||||||
// Parse the manifest to get file entries
|
// Parse the manifest to get file entries. Loading a manifest logs to
|
||||||
|
// the process-global logger.
|
||||||
|
runMu.Lock()
|
||||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||||
|
runMu.Unlock()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
files := manifest.Files()
|
files := manifest.Files()
|
||||||
@@ -365,7 +373,10 @@ func TestFetchHashMismatch(t *testing.T) {
|
|||||||
// Generate and parse manifest
|
// Generate and parse manifest
|
||||||
manifestData := scanToManifest(t, sourceFs)
|
manifestData := scanToManifest(t, sourceFs)
|
||||||
|
|
||||||
|
// Loading a manifest logs to the process-global logger.
|
||||||
|
runMu.Lock()
|
||||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||||
|
runMu.Unlock()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
files := manifest.Files()
|
files := manifest.Files()
|
||||||
@@ -411,7 +422,10 @@ func TestFetchSizeMismatch(t *testing.T) {
|
|||||||
// Generate and parse manifest
|
// Generate and parse manifest
|
||||||
manifestData := scanToManifest(t, sourceFs)
|
manifestData := scanToManifest(t, sourceFs)
|
||||||
|
|
||||||
|
// Loading a manifest logs to the process-global logger.
|
||||||
|
runMu.Lock()
|
||||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||||
|
runMu.Unlock()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
files := manifest.Files()
|
files := manifest.Files()
|
||||||
@@ -502,8 +516,9 @@ func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
|||||||
[]string{cmd.Name, server.URL + "/index.mf"})
|
[]string{cmd.Name, server.URL + "/index.mf"})
|
||||||
})
|
})
|
||||||
require.ErrorIs(t, err, errManifestTooLarge, cmd.Name)
|
require.ErrorIs(t, err, errManifestTooLarge, cmd.Name)
|
||||||
require.ErrorContains(t, err,
|
require.EqualError(t, err,
|
||||||
"maximum allowed size of 65536 bytes", cmd.Name)
|
"download manifest: file exceeds maximum allowed size of 65536 bytes",
|
||||||
|
cmd.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
leftover, err := os.ReadDir(tmpDir)
|
leftover, err := os.ReadDir(tmpDir)
|
||||||
@@ -515,14 +530,17 @@ func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
|||||||
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
|
||||||
sourceFs := afero.NewMemMapFs()
|
sourceFs := afero.NewMemMapFs()
|
||||||
// Create content large enough to trigger multiple progress updates
|
// Content large enough that io.Copy writes it in at least four chunks
|
||||||
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
||||||
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
|
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
|
||||||
|
|
||||||
// Generate and parse manifest
|
// Generate and parse manifest
|
||||||
manifestData := scanToManifest(t, sourceFs)
|
manifestData := scanToManifest(t, sourceFs)
|
||||||
|
|
||||||
|
// Loading a manifest logs to the process-global logger.
|
||||||
|
runMu.Lock()
|
||||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||||
|
runMu.Unlock()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
files := manifest.Files()
|
files := manifest.Files()
|
||||||
@@ -558,8 +576,11 @@ func TestFetchProgress(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
|
start := time.Now()
|
||||||
err = downloadFile(context.Background(), testClient(),
|
err = downloadFile(context.Background(), testClient(),
|
||||||
server.URL+"/large.txt", ".", "large.txt", files[0], progress)
|
server.URL+"/large.txt", ".", "large.txt", files[0], progress)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
close(progress)
|
close(progress)
|
||||||
<-done
|
<-done
|
||||||
|
|
||||||
@@ -568,6 +589,12 @@ func TestFetchProgress(t *testing.T) {
|
|||||||
// Verify we got progress updates
|
// Verify we got progress updates
|
||||||
assert.NotEmpty(t, progressUpdates, "should have received progress updates")
|
assert.NotEmpty(t, progressUpdates, "should have received progress updates")
|
||||||
|
|
||||||
|
// At most one update per second, plus the one reporting the file
|
||||||
|
// complete: a download that takes under a second sends two at most,
|
||||||
|
// however many chunks it writes.
|
||||||
|
assert.LessOrEqual(t, len(progressUpdates), 2+int(elapsed/time.Second),
|
||||||
|
"one progress update per second at most, plus the last")
|
||||||
|
|
||||||
// Verify final progress shows complete
|
// Verify final progress shows complete
|
||||||
if len(progressUpdates) > 0 {
|
if len(progressUpdates) > 0 {
|
||||||
last := progressUpdates[len(progressUpdates)-1]
|
last := progressUpdates[len(progressUpdates)-1]
|
||||||
@@ -605,27 +632,27 @@ func TestFetchRefusesSymlinks(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"parent directory", "sub/deeper/file.txt", "sub", ".",
|
"parent directory", "sub/deeper/file.txt", "sub", ".",
|
||||||
"failed to download sub/deeper/file.txt",
|
"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", ".",
|
||||||
"failed to download docs/data/passwd",
|
"download docs/data/passwd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"temp file", testFileTxt, ".file.txt.tmp", newFile,
|
"temp file", testFileTxt, ".file.txt.tmp", newFile,
|
||||||
"failed to download " + testFileTxt,
|
"download " + testFileTxt,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file", testFileTxt, testFileTxt, newFile,
|
"file", testFileTxt, testFileTxt, newFile,
|
||||||
"failed to download " + testFileTxt,
|
"download " + testFileTxt,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"manifest temp file", testFileTxt, tempPathFor(defaultManifestName), newFile,
|
"manifest temp file", testFileTxt, tempPathFor(defaultManifestName), newFile,
|
||||||
"failed to save manifest",
|
"save manifest",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"manifest", testFileTxt, defaultManifestName, newFile,
|
"manifest", testFileTxt, defaultManifestName, newFile,
|
||||||
"failed to save manifest",
|
"save manifest",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +719,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),
|
||||||
"failed to download sub/"+testFileTxt+": symlink in path not allowed: "+link)
|
"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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +789,8 @@ 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, fmt.Sprintf("HTTP %d", tt.status))
|
require.EqualError(t, err,
|
||||||
|
fmt.Sprintf("unexpected HTTP status %d", tt.status))
|
||||||
assert.Equal(t, tt.requests, requests.Load())
|
assert.Equal(t, tt.requests, requests.Load())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -869,23 +897,59 @@ func TestGetHonorsRetryAfter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetStopsWaitingWhenCanceled gives get a server that always answers
|
// instant503Transport answers every request with a 503 at once, so the
|
||||||
// 503 and an hour's wait before each retry, then lets the context expire
|
// 503 is in hand before any deadline can pass.
|
||||||
// during that wait. get returning at all shows it stopped waiting.
|
type instant503Transport struct{}
|
||||||
|
|
||||||
|
func (instant503Transport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Body: http.NoBody,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetStopsWaitingWhenCanceled answers every request with a 503 and
|
||||||
|
// sets an hour's wait before each retry, then lets the context expire
|
||||||
|
// during that wait. get returning at all shows it stopped waiting; it must
|
||||||
|
// return the deadline error and keep the 503 with it.
|
||||||
func TestGetStopsWaitingWhenCanceled(t *testing.T) {
|
func TestGetStopsWaitingWhenCanceled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
server := httptest.NewServer(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := testClient()
|
client := testClient()
|
||||||
|
client.client.Transport = instant503Transport{}
|
||||||
client.firstDelay = time.Hour
|
client.firstDelay = time.Hour
|
||||||
|
|
||||||
err := getNothing(client, server.URL, 50*time.Millisecond)
|
err := getNothing(client, "http://example.invalid/", 50*time.Millisecond)
|
||||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.ErrorIs(t, err, errHTTPStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// late503Transport answers every request with a 503, but only once the
|
||||||
|
// request's context has ended. A real server cannot stand in for it: the
|
||||||
|
// client drops a response still on its way when the context ends.
|
||||||
|
type late503Transport struct{}
|
||||||
|
|
||||||
|
func (late503Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
<-req.Context().Done()
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusServiceUnavailable,
|
||||||
|
Body: http.NoBody,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetStopsWhenContextEndsDuringRequest lets the context expire while
|
||||||
|
// the first request is still waiting for its 503. get must return the
|
||||||
|
// deadline error and keep the 503 with it.
|
||||||
|
func TestGetStopsWhenContextEndsDuringRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := testClient()
|
||||||
|
client.client.Transport = late503Transport{}
|
||||||
|
|
||||||
|
err := getNothing(client, "http://example.invalid/", 50*time.Millisecond)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.ErrorIs(t, err, errHTTPStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDownloadFileRetriesToSuccess serves a file that fails twice before
|
// TestDownloadFileRetriesToSuccess serves a file that fails twice before
|
||||||
@@ -895,9 +959,12 @@ func TestGetStopsWaitingWhenCanceled(t *testing.T) {
|
|||||||
//nolint:paralleltest // changes the process-global working directory
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
||||||
content := []byte("the whole file, every byte of it")
|
content := []byte("the whole file, every byte of it")
|
||||||
|
manifestData := manifestOf(t, map[string][]byte{testFileTxt: content})
|
||||||
|
|
||||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(
|
// Loading a manifest logs to the process-global logger.
|
||||||
manifestOf(t, map[string][]byte{testFileTxt: content})))
|
runMu.Lock()
|
||||||
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||||
|
runMu.Unlock()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var requests atomic.Int32
|
var requests atomic.Int32
|
||||||
@@ -920,8 +987,11 @@ func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
|||||||
|
|
||||||
chdirTemp(t)
|
chdirTemp(t)
|
||||||
|
|
||||||
err = downloadFile(context.Background(), testClient(),
|
// downloadFile logs each retry to the process-global logger.
|
||||||
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
|
err = runLocked(func() error {
|
||||||
|
return downloadFile(context.Background(), testClient(),
|
||||||
|
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int32(3), requests.Load())
|
assert.Equal(t, int32(3), requests.Load())
|
||||||
|
|
||||||
@@ -1187,25 +1257,28 @@ func TestFetchIntoDest(t *testing.T) {
|
|||||||
// TestFetchRequireSignature runs fetch with --require-signature. A
|
// TestFetchRequireSignature runs fetch with --require-signature. A
|
||||||
// manifest that is unsigned, or signed by another key, must stop fetch
|
// manifest that is unsigned, or signed by another key, must stop fetch
|
||||||
// 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. The signed cases need gpg and are skipped
|
// required key lets it through. A manifest signed by another key whose
|
||||||
// without it, as the other signing tests are.
|
// embedded public key block also holds the required key must stop fetch
|
||||||
//
|
// too.
|
||||||
//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, err := signedChecker(t, manifest).
|
signer := string(signedChecker(t, manifest).Signer())
|
||||||
ExtractEmbeddedSigningKeyFP(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assertFetchRefused(t, manifest, files,
|
assertFetchRefused(t, manifest, files,
|
||||||
"embedded signing key fingerprint "+signer+" does not match required "+msgFpB,
|
"embedded signing key fingerprint "+signer+" does not match required "+msgFpB,
|
||||||
@@ -1223,14 +1296,25 @@ func TestFetchRequireSignature(t *testing.T) {
|
|||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
assert.Equal(t, files[testFileTxt], filesUnder(t, dest)[testFileTxt])
|
assert.Equal(t, files[testFileTxt], filesUnder(t, dest)[testFileTxt])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("signed by another key embedded after the required one", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifest, required := manifestSignedByAnotherKey(t, files)
|
||||||
|
|
||||||
|
assertFetchRefused(t, manifest, files,
|
||||||
|
"parse manifest: "+
|
||||||
|
"embedded public key block must hold exactly one key, found 2",
|
||||||
|
"--"+flagRequireSignature, required)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchRefusesListedManifestName fetches manifests that list, at the
|
// TestFetchRefusesListedManifestName fetches manifests that list, at the
|
||||||
// top of the tree, the name fetch saves the manifest under or that name's
|
// top of the tree, the name fetch saves the manifest under or that name's
|
||||||
// temp file: as a file, as a directory, in capitals, and with a leading
|
// temp file: as a file, as a directory and in capitals. Saving the
|
||||||
// "./". Saving the manifest would replace or remove what is listed there,
|
// manifest would replace or remove what is listed there, so fetch must
|
||||||
// so fetch must refuse the manifest before it creates the destination or
|
// refuse the manifest before it creates the destination or requests any
|
||||||
// requests any file.
|
// file.
|
||||||
func TestFetchRefusesListedManifestName(t *testing.T) {
|
func TestFetchRefusesListedManifestName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1239,7 +1323,6 @@ func TestFetchRefusesListedManifestName(t *testing.T) {
|
|||||||
tempPathFor(defaultManifestName),
|
tempPathFor(defaultManifestName),
|
||||||
defaultManifestName + "/" + testFileTxt,
|
defaultManifestName + "/" + testFileTxt,
|
||||||
"INDEX.MF",
|
"INDEX.MF",
|
||||||
"./" + defaultManifestName,
|
|
||||||
} {
|
} {
|
||||||
t.Run(listed, func(t *testing.T) {
|
t.Run(listed, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -1315,9 +1398,9 @@ func TestFetchRefusesListedTempName(t *testing.T) {
|
|||||||
// directory, one of them under a spelling the manifest does not list,
|
// directory, one of them under a spelling the manifest does not list,
|
||||||
// and check reports that file as not in the manifest. So fetch must
|
// and check reports that file as not in the manifest. So fetch must
|
||||||
// refuse each on every filesystem before it creates the destination or
|
// refuse each on every filesystem before it creates the destination or
|
||||||
// requests any file. A file and a directory with the same name, and a
|
// requests any file. A file and a directory with the same name are
|
||||||
// file listed twice, are refused the same way. A manifest whose names
|
// refused the same way. A manifest whose names differ in more than
|
||||||
// differ in more than letter case is fetched in full.
|
// letter case is fetched in full.
|
||||||
func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1327,7 +1410,6 @@ func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
|||||||
{"Dir", "dir/x", "dir/x (the file Dir)"},
|
{"Dir", "dir/x", "dir/x (the file Dir)"},
|
||||||
{"Dir/a.txt", "dir/b.txt", "dir/b.txt (the directory Dir)"},
|
{"Dir/a.txt", "dir/b.txt", "dir/b.txt (the directory Dir)"},
|
||||||
{"dir", "dir/x", "dir/x (the file dir)"},
|
{"dir", "dir/x", "dir/x (the file dir)"},
|
||||||
{"./x.txt", "x.txt", "x.txt (the file ./x.txt)"},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.first+" and "+tc.second, func(t *testing.T) {
|
t.Run(tc.first+" and "+tc.second, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -1388,14 +1470,18 @@ func TestFetchSetsRecordedMode(t *testing.T) {
|
|||||||
require.NoError(t, afero.WriteFile(sourceFs, "/"+p, content, modes[p]))
|
require.NoError(t, afero.WriteFile(sourceFs, "/"+p, content, modes[p]))
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := mfer.NewScannerWithOptions(&mfer.ScannerOptions{
|
|
||||||
Fs: sourceFs,
|
|
||||||
IncludePermissions: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, scanner.EnumerateFS(sourceFs, "/", nil))
|
|
||||||
|
|
||||||
var manifest bytes.Buffer
|
var manifest bytes.Buffer
|
||||||
require.NoError(t, scanner.ToManifest(context.Background(), &manifest, nil))
|
|
||||||
|
// The scan logs to the process-global logger.
|
||||||
|
require.NoError(t, runLocked(func() error {
|
||||||
|
scanner := mfer.NewScannerWithOptions(&mfer.ScannerOptions{
|
||||||
|
Fs: sourceFs,
|
||||||
|
IncludePermissions: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, scanner.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
|
return scanner.ToManifest(context.Background(), &manifest, nil)
|
||||||
|
}))
|
||||||
|
|
||||||
tree := fetchTestHandler(manifest.Bytes(), files)
|
tree := fetchTestHandler(manifest.Bytes(), files)
|
||||||
|
|
||||||
@@ -1557,8 +1643,7 @@ func manifestWithMode(t *testing.T, path string, content []byte, mode uint32) []
|
|||||||
}
|
}
|
||||||
|
|
||||||
// builtManifest returns a manifest of files, built directly rather than
|
// builtManifest returns a manifest of files, built directly rather than
|
||||||
// scanned, since a scan lists no hidden files and never a path starting
|
// scanned, since a scan lists no hidden files.
|
||||||
// with "./".
|
|
||||||
func builtManifest(t *testing.T, files map[string][]byte) []byte {
|
func builtManifest(t *testing.T, files map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+130
-66
@@ -28,9 +28,19 @@ const (
|
|||||||
scanProgressInterval = 100
|
scanProgressInterval = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// errEntryMissingMtime indicates a manifest entry that carries no
|
var (
|
||||||
// modification time where one is required to carry it forward unchanged.
|
// errEntryMissingMtime indicates a manifest entry that carries no
|
||||||
var errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
// modification time where one is required to carry it forward unchanged.
|
||||||
|
errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
||||||
|
|
||||||
|
// errEntryMissingHash indicates a manifest entry that carries no hash
|
||||||
|
// to carry forward.
|
||||||
|
errEntryMissingHash = errors.New("manifest entry has no hash")
|
||||||
|
)
|
||||||
|
|
||||||
|
// errSignKeyNeeded indicates a signed manifest given to freshen with no
|
||||||
|
// key to sign the rewritten one.
|
||||||
|
var errSignKeyNeeded = errors.New("give --sign-key to keep it signed")
|
||||||
|
|
||||||
// FreshenStatus contains progress information for the freshen operation.
|
// FreshenStatus contains progress information for the freshen operation.
|
||||||
type FreshenStatus struct {
|
type FreshenStatus struct {
|
||||||
@@ -165,8 +175,7 @@ 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 fmt.Errorf(
|
return err
|
||||||
"freshen: failed to compute relative path for %s: %w", path, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle dotfiles
|
// Handle dotfiles
|
||||||
@@ -193,6 +202,13 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
|
|||||||
info = realInfo
|
info = realInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opening a named pipe can block, and reading a device may never end.
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
log.Verbosef("skipping %s: not a regular file", relPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, excluded := range s.excluded {
|
for _, excluded := range s.excluded {
|
||||||
if os.SameFile(info, excluded) {
|
if os.SameFile(info, excluded) {
|
||||||
return nil
|
return nil
|
||||||
@@ -280,13 +296,8 @@ 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
|
// Use existing entry; the error names the entry
|
||||||
err := addExistingToBuilder(h.builder, e.existing)
|
return 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
|
||||||
@@ -294,26 +305,31 @@ 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 fmt.Errorf("failed to open %s: %w", e.path, err)
|
return 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 fmt.Errorf("failed to hash %s: %w", e.path, err)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scan recorded the size; a file that grew or shrank since would
|
||||||
|
// get an entry whose size and hash describe different contents.
|
||||||
|
if bytesRead != e.size {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w for %q: %d bytes when scanned but %d bytes when read; "+
|
||||||
|
"the file changed while freshen ran",
|
||||||
|
errSizeMismatch, e.path, e.size, bytesRead,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
h.hashedBytes += bytesRead
|
h.hashedBytes += bytesRead
|
||||||
h.hashedFiles++
|
h.hashedFiles++
|
||||||
|
|
||||||
// Add to builder with computed hash
|
// Add to builder with computed hash; a refused path is named in the error
|
||||||
err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash)
|
return 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
|
||||||
@@ -325,7 +341,7 @@ func writeFreshenedManifest(
|
|||||||
|
|
||||||
outFile, err := afs.Create(tmpPath)
|
outFile, err := afs.Create(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = builder.Build(ctx, outFile)
|
err = builder.Build(ctx, outFile)
|
||||||
@@ -334,7 +350,7 @@ func writeFreshenedManifest(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = afs.Remove(tmpPath)
|
_ = afs.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write manifest: %w", err)
|
return fmt.Errorf("build manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename temp to final
|
// Rename temp to final
|
||||||
@@ -342,29 +358,46 @@ func writeFreshenedManifest(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = afs.Remove(tmpPath)
|
_ = afs.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to rename manifest: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newFreshenBuilder constructs the manifest builder configured from CLI
|
// newFreshenBuilder constructs the manifest builder configured from CLI
|
||||||
// flags.
|
// flags, and returns the fingerprint of the --sign-key key, or "" when
|
||||||
func newFreshenBuilder(cmd *cli.Command) *mfer.Builder {
|
// none is given. A --sign-key given with an empty value is refused.
|
||||||
|
func (mfa *CLIApp) newFreshenBuilder(
|
||||||
|
cmd *cli.Command,
|
||||||
|
) (*mfer.Builder, string, error) {
|
||||||
builder := mfer.NewBuilder()
|
builder := mfer.NewBuilder()
|
||||||
if cmd.Bool("include-timestamps") {
|
if cmd.Bool("include-timestamps") {
|
||||||
builder.SetIncludeTimestamps(true)
|
builder.SetIncludeTimestamps(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var signKeyFingerprint string
|
||||||
|
|
||||||
// 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 cmd.IsSet("sign-key") {
|
||||||
builder.SetSigningOptions(&mfer.SigningOptions{
|
signKey := cmd.String("sign-key")
|
||||||
KeyID: mfer.GPGKeyID(signKey),
|
if signKey == "" {
|
||||||
})
|
return nil, "", errEmptySignKey
|
||||||
log.Infof("signing manifest with GPG key: %s", signKey)
|
}
|
||||||
|
|
||||||
|
signing, err := mfa.signingOptions(signKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
signKeyFingerprint, err = mfer.SecretKeyFingerprint(signing.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%s: %w", signKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.SetSigningOptions(signing)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder
|
return builder, signKeyFingerprint, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// freshenScan runs the scan phase against the loaded manifest entries
|
// freshenScan runs the scan phase against the loaded manifest entries
|
||||||
@@ -390,6 +423,14 @@ func (mfa *CLIApp) freshenScan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The walk does not follow a symlink at its top, so a base directory
|
||||||
|
// named through one is resolved first. If that fails, the base is
|
||||||
|
// walked as named and the walk reports the problem.
|
||||||
|
resolved, err := filepath.EvalSymlinks(absBase)
|
||||||
|
if err == nil {
|
||||||
|
absBase = resolved
|
||||||
|
}
|
||||||
|
|
||||||
scanner := &freshenScanner{
|
scanner := &freshenScanner{
|
||||||
fs: mfa.Fs,
|
fs: mfa.Fs,
|
||||||
absBase: absBase,
|
absBase: absBase,
|
||||||
@@ -401,14 +442,14 @@ func (mfa *CLIApp) freshenScan(
|
|||||||
existingByPath: existingByPath,
|
existingByPath: existingByPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := afero.Walk(mfa.Fs, absBase, scanner.walk)
|
err = afero.Walk(mfa.Fs, absBase, scanner.walk)
|
||||||
|
|
||||||
if showProgress {
|
if showProgress {
|
||||||
log.ProgressDone()
|
log.ProgressDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("failed to scan filesystem: %w", err)
|
return nil, 0, fmt.Errorf("scan filesystem: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remaining entries in existingByPath are removed files
|
// Remaining entries in existingByPath are removed files
|
||||||
@@ -444,7 +485,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.
|
// the context is canceled, and ends the hasher's progress line.
|
||||||
func runFreshenHash(
|
func runFreshenHash(
|
||||||
ctx context.Context, hasher *freshenHasher, entries []*freshenEntry,
|
ctx context.Context, hasher *freshenHasher, entries []*freshenEntry,
|
||||||
) error {
|
) error {
|
||||||
@@ -461,14 +502,26 @@ func runFreshenHash(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasher.showProgress && hasher.filesToHash > 0 {
|
||||||
|
log.ProgressDone()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadExistingEntries loads the manifest and indexes its file entries
|
// existingManifest is what freshen uses of the manifest it rewrites.
|
||||||
// by path.
|
type existingManifest struct {
|
||||||
func (mfa *CLIApp) loadExistingEntries(
|
entriesByPath map[string]*mfer.MFFilePath
|
||||||
manifestPath string,
|
signer string // fingerprint of the key it is signed with, "" if unsigned
|
||||||
) (map[string]*mfer.MFFilePath, error) {
|
hasCreatedAt bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadExistingManifest loads the manifest and indexes its file entries
|
||||||
|
// by path. It refuses a signed manifest when no --sign-key is given, as
|
||||||
|
// the rewritten manifest would be unsigned.
|
||||||
|
func (mfa *CLIApp) loadExistingManifest(
|
||||||
|
cmd *cli.Command, manifestPath string,
|
||||||
|
) (*existingManifest, error) {
|
||||||
log.Infof("loading manifest from %s", manifestPath)
|
log.Infof("loading manifest from %s", manifestPath)
|
||||||
|
|
||||||
// Load existing manifest
|
// Load existing manifest
|
||||||
@@ -477,7 +530,11 @@ func (mfa *CLIApp) loadExistingEntries(
|
|||||||
Fs: mfa.Fs,
|
Fs: mfa.Fs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load manifest: %w", err)
|
return nil, fmt.Errorf("load manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if signer := manifest.Signer(); signer != nil && cmd.String("sign-key") == "" {
|
||||||
|
return nil, fmt.Errorf("manifest is signed by %s: %w", signer, errSignKeyNeeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFiles := manifest.Files()
|
existingFiles := manifest.Files()
|
||||||
@@ -489,7 +546,11 @@ func (mfa *CLIApp) loadExistingEntries(
|
|||||||
existingByPath[f.GetPath()] = f
|
existingByPath[f.GetPath()] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingByPath, nil
|
return &existingManifest{
|
||||||
|
entriesByPath: existingByPath,
|
||||||
|
signer: string(manifest.Signer()),
|
||||||
|
hasCreatedAt: manifest.CreatedAt() != nil,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mfa *CLIApp) freshenManifestOperation(
|
func (mfa *CLIApp) freshenManifestOperation(
|
||||||
@@ -497,29 +558,30 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
) error {
|
) error {
|
||||||
log.Debug("freshenManifestOperation()")
|
log.Debug("freshenManifestOperation()")
|
||||||
|
|
||||||
basePath := cmd.String("base")
|
|
||||||
showProgress := cmd.Bool("progress")
|
|
||||||
|
|
||||||
// Find manifest file
|
// Find manifest file
|
||||||
manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
|
manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("freshen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:contextcheck // mfer loads a manifest without a context
|
|
||||||
existingByPath, err := mfa.loadExistingEntries(manifestPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
absBase, err := filepath.Abs(basePath)
|
builder, signKeyFingerprint, err := mfa.newFreshenBuilder(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("freshen: invalid base path: %w", err)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := mfa.loadExistingManifest(cmd, manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
absBase, err := filepath.Abs(resolveBasePath(cmd, manifestPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid base path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Scan filesystem
|
// Phase 1: Scan filesystem
|
||||||
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
||||||
existingByPath)
|
existing.entriesByPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -536,11 +598,11 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
hasher := &freshenHasher{
|
hasher := &freshenHasher{
|
||||||
fs: mfa.Fs,
|
fs: mfa.Fs,
|
||||||
absBase: absBase,
|
absBase: absBase,
|
||||||
showProgress: showProgress,
|
showProgress: cmd.Bool("progress"),
|
||||||
totalHashBytes: totalHashBytes,
|
totalHashBytes: totalHashBytes,
|
||||||
filesToHash: filesToHash,
|
filesToHash: filesToHash,
|
||||||
startHash: time.Now(),
|
startHash: time.Now(),
|
||||||
builder: newFreshenBuilder(cmd),
|
builder: builder,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = runFreshenHash(ctx, hasher, scanner.entries)
|
err = runFreshenHash(ctx, hasher, scanner.entries)
|
||||||
@@ -548,16 +610,18 @@ 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)
|
||||||
|
|
||||||
// Skip writing if nothing changed
|
// Skip writing only when the manifest would come out the same: nothing
|
||||||
if scanner.changed == 0 && scanner.added == 0 && removed == 0 {
|
// in the tree changed, and the manifest already holds what --sign-key,
|
||||||
|
// --include-timestamps and --include-permissions ask for. The scan
|
||||||
|
// counts a file whose recorded mode is not what --include-permissions
|
||||||
|
// asks for as changed.
|
||||||
|
if scanner.changed == 0 && scanner.added == 0 && removed == 0 &&
|
||||||
|
existing.signer == signKeyFingerprint &&
|
||||||
|
existing.hasCreatedAt == cmd.Bool("include-timestamps") {
|
||||||
log.Infof("manifest unchanged, skipping write")
|
log.Infof("manifest unchanged, skipping write")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -606,9 +670,7 @@ func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returned unwrapped: the caller renders this as
|
// Returned unwrapped: a read error already names the file.
|
||||||
// "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
|
||||||
}
|
}
|
||||||
@@ -637,6 +699,8 @@ func addFileToBuilder(
|
|||||||
// Entries reach this path only when recordEntry classified them as
|
// Entries reach this path only when recordEntry classified them as
|
||||||
// unchanged, which requires a recorded mtime, so an absent mtime here is
|
// unchanged, which requires a recorded mtime, so an absent mtime here is
|
||||||
// an error rather than something to paper over with the Unix epoch.
|
// an error rather than something to paper over with the Unix epoch.
|
||||||
|
// Loading refuses an entry with no hash; one that got here anyway is an
|
||||||
|
// error too, not a file to leave out of the manifest.
|
||||||
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
||||||
mtime, ok := entryMtime(entry)
|
mtime, ok := entryMtime(entry)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -644,7 +708,7 @@ func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(entry.GetHashes()) == 0 {
|
if len(entry.GetHashes()) == 0 {
|
||||||
return nil
|
return fmt.Errorf("%w: %s", errEntryMissingHash, entry.GetPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,6 +70,11 @@ func runFreshen(t *testing.T, fs afero.Fs, root, manifestPath string) {
|
|||||||
func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// Loading a manifest logs to the process-global logger, so serialize
|
||||||
|
// with the CLI runs.
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
Path: path,
|
Path: path,
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
@@ -78,6 +84,23 @@ func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
|||||||
return manifest.Files()
|
return manifest.Files()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// manifestCreatedAt returns the createdAt timestamp of the manifest at
|
||||||
|
// path, or nil if it has none.
|
||||||
|
func manifestCreatedAt(t *testing.T, fs afero.Fs, path string) *mfer.Timestamp {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
runMu.Lock()
|
||||||
|
defer runMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
|
Path: path,
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return manifest.CreatedAt()
|
||||||
|
}
|
||||||
|
|
||||||
// TestFreshenUnchanged freshens a tree that has not changed since gen
|
// TestFreshenUnchanged freshens a tree that has not changed since gen
|
||||||
// made its manifest: the manifest must list the same entries as before.
|
// made its manifest: the manifest must list the same entries as before.
|
||||||
func TestFreshenUnchanged(t *testing.T) {
|
func TestFreshenUnchanged(t *testing.T) {
|
||||||
@@ -135,6 +158,41 @@ func TestFreshenRecordsModeOnlyWhenAsked(t *testing.T) {
|
|||||||
recordedModes())
|
recordedModes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFreshenIncludeTimestampsOnUnchangedTree runs freshen
|
||||||
|
// --include-timestamps on a tree that has not changed since gen made its
|
||||||
|
// manifest, which has no createdAt timestamp. freshen must rewrite the
|
||||||
|
// manifest with one. Run again with the flag it must leave the manifest as
|
||||||
|
// it is; run without it, it must rewrite the manifest without one.
|
||||||
|
func TestFreshenIncludeTimestampsOnUnchangedTree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
root, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "hello"})
|
||||||
|
|
||||||
|
freshenWithTimestamps := func() {
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdFreshen, "-q", "--include-timestamps",
|
||||||
|
testFlagBase, root, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
freshenWithTimestamps()
|
||||||
|
assert.NotNil(t, manifestCreatedAt(t, fs, manifestPath))
|
||||||
|
|
||||||
|
before, err := afero.ReadFile(fs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
freshenWithTimestamps()
|
||||||
|
|
||||||
|
after, err := afero.ReadFile(fs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, before, after, "manifest that has a createdAt was rewritten")
|
||||||
|
|
||||||
|
runFreshen(t, fs, root, manifestPath)
|
||||||
|
assert.Nil(t, manifestCreatedAt(t, fs, manifestPath))
|
||||||
|
}
|
||||||
|
|
||||||
// assertManifestLists asserts that the manifest at manifestPath lists
|
// assertManifestLists asserts that the manifest at manifestPath lists
|
||||||
// exactly the files in want, each with the size and SHA-256 hash of its
|
// exactly the files in want, each with the size and SHA-256 hash of its
|
||||||
// content in want and the mtime of the file of that name under root.
|
// content in want and the mtime of the file of that name under root.
|
||||||
@@ -242,6 +300,58 @@ func TestFreshenWithChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// growingFileFs appends to one file each time it is opened, as a program
|
||||||
|
// still writing to the file would between freshen's scan and its hash.
|
||||||
|
type growingFileFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // Open must return afero.File to satisfy afero.Fs.
|
||||||
|
func (f growingFileFs) Open(name string) (afero.File, error) {
|
||||||
|
if name == f.path {
|
||||||
|
file, err := f.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = file.WriteString(" world")
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenFileChangedSinceScan has a new file grow after freshen's scan
|
||||||
|
// recorded its size and before freshen hashes it: freshen must fail and
|
||||||
|
// leave the manifest as it was.
|
||||||
|
func TestFreshenFileChangedSinceScan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
osFs := afero.NewOsFs()
|
||||||
|
root, manifestPath := setupFreshenDir(t, osFs,
|
||||||
|
map[string]string{testFileTxt: "content"})
|
||||||
|
grown := filepath.Join(root, "log.txt")
|
||||||
|
writeTestFile(t, osFs, grown, "hello")
|
||||||
|
|
||||||
|
before, err := afero.ReadFile(osFs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdFreshen, "-q", testFlagBase, root, manifestPath,
|
||||||
|
}, growingFileFs{Fs: osFs, path: grown})
|
||||||
|
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
after, err := afero.ReadFile(osFs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, before, after, "manifest changed")
|
||||||
|
}
|
||||||
|
|
||||||
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
|
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
|
||||||
// subdirectory of the tree it lists: the manifest is not listed, while an
|
// subdirectory of the tree it lists: the manifest is not listed, while an
|
||||||
// ordinary file of the same name at the top of the tree is. The manifest
|
// ordinary file of the same name at the top of the tree is. The manifest
|
||||||
@@ -300,6 +410,84 @@ 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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenDirectoryNamedThroughSymlink adds a file to a tree after gen
|
||||||
|
// made its manifest, then freshens it with the tree named through a symlink,
|
||||||
|
// given as the argument or as the working directory: the manifest lists the
|
||||||
|
// files in the tree, and leaves out a symlink inside it, as freshen does
|
||||||
|
// without --follow-symlinks.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestFreshenDirectoryNamedThroughSymlink(t *testing.T) {
|
||||||
|
// Paths are relative to a temp dir holding link, a symlink to the tree.
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
workDir string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
"argument": {".", []string{testLink}},
|
||||||
|
"working directory": {testLink, nil},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tree, manifestPath := setupFreshenDir(t, fs,
|
||||||
|
map[string]string{testFileTxt: "in the tree"})
|
||||||
|
writeTestFile(t, fs, filepath.Join(tree, "later.txt"), "added later")
|
||||||
|
require.NoError(t,
|
||||||
|
os.Symlink(testFileTxt, filepath.Join(tree, "alias.txt")))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.Symlink(tree, filepath.Join(root, testLink)))
|
||||||
|
|
||||||
|
// t.Chdir sets PWD to the path it is given, as a shell does, and
|
||||||
|
// os.Getwd returns PWD when it names the working directory.
|
||||||
|
t.Chdir(filepath.Join(root, tc.workDir))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdFreshen, "-q"}, tc.args,
|
||||||
|
), fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assertManifestLists(t, fs, tree, manifestPath, map[string]string{
|
||||||
|
testFileTxt: "in the tree", "later.txt": "added later",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -357,7 +545,11 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
|||||||
s := &freshenScanner{
|
s := &freshenScanner{
|
||||||
existingByPath: map[string]*mfer.MFFilePath{relPath: tc.entry},
|
existingByPath: map[string]*mfer.MFFilePath{relPath: tc.entry},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordEntry logs to the process-global logger.
|
||||||
|
runMu.Lock()
|
||||||
s.recordEntry(relPath, info)
|
s.recordEntry(relPath, info)
|
||||||
|
runMu.Unlock()
|
||||||
|
|
||||||
require.Len(t, s.entries, 1)
|
require.Len(t, s.entries, 1)
|
||||||
assert.Equal(t, tc.needsHash, s.entries[0].needsHash)
|
assert.Equal(t, tc.needsHash, s.entries[0].needsHash)
|
||||||
@@ -392,6 +584,24 @@ func TestFreshenAddExistingRejectsMissingMtime(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "file1.txt")
|
assert.Contains(t, err.Error(), "file1.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFreshenAddExistingRejectsMissingHash pins that an entry with no hash
|
||||||
|
// is an error rather than a file left out of the rebuilt manifest.
|
||||||
|
func TestFreshenAddExistingRejectsMissingHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b := mfer.NewBuilder()
|
||||||
|
entry := &mfer.MFFilePath{
|
||||||
|
Path: "data.bin",
|
||||||
|
Size: 8,
|
||||||
|
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := addExistingToBuilder(b, entry)
|
||||||
|
require.ErrorIs(t, err, errEntryMissingHash)
|
||||||
|
assert.Contains(t, err.Error(), "data.bin")
|
||||||
|
assert.Zero(t, b.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
// TestFreshenAddExistingRejectsShortHash pins that an existing manifest
|
// TestFreshenAddExistingRejectsShortHash pins that an existing manifest
|
||||||
// entry whose hash the builder refuses is reported as a problem with the
|
// entry whose hash the builder refuses is reported as a problem with the
|
||||||
// manifest, with the command that fixes it.
|
// manifest, with the command that fixes it.
|
||||||
|
|||||||
+102
-39
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +27,10 @@ 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")
|
||||||
|
// errEmptySignKey indicates --sign-key given with an empty value.
|
||||||
|
errEmptySignKey = errors.New("--sign-key must not be empty")
|
||||||
)
|
)
|
||||||
|
|
||||||
// reportEnumProgress renders enumeration progress until the channel
|
// reportEnumProgress renders enumeration progress until the channel
|
||||||
@@ -74,7 +79,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("generate: invalid path %q: %w", inputPath, err)
|
return nil, fmt.Errorf("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 {
|
||||||
@@ -88,9 +93,41 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScannerOptions constructs scanner options from the CLI flags.
|
// outputPath returns the file gen writes the manifest to: the one --output
|
||||||
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
|
// names, or else index.mf in the directory the only argument names, or
|
||||||
output := cmd.String("output")
|
// beside the file it names, or else in the current directory. An --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. A --sign-key given with an empty
|
||||||
|
// value is refused.
|
||||||
|
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"),
|
||||||
@@ -110,14 +147,21 @@ func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 cmd.IsSet("sign-key") {
|
||||||
opts.SigningOptions = &mfer.SigningOptions{
|
signKey := cmd.String("sign-key")
|
||||||
KeyID: mfer.GPGKeyID(signKey),
|
if signKey == "" {
|
||||||
|
return nil, errEmptySignKey
|
||||||
}
|
}
|
||||||
log.Infof("signing manifest with GPG key: %s", signKey)
|
|
||||||
|
signing, err := mfa.signingOptions(signKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.SigningOptions = signing
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// enumerateInputs runs the enumeration phase over the argument paths,
|
// enumerateInputs runs the enumeration phase over the argument paths,
|
||||||
@@ -129,8 +173,7 @@ 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(
|
return fmt.Errorf("enumerate current directory: %w", err)
|
||||||
"generate: failed to enumerate current directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -144,7 +187,7 @@ func (mfa *CLIApp) enumerateInputs(
|
|||||||
|
|
||||||
err = s.EnumeratePaths(enumProgress, paths...)
|
err = s.EnumeratePaths(enumProgress, paths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
return fmt.Errorf("enumerate files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -203,23 +246,59 @@ 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()")
|
||||||
|
|
||||||
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
|
outputPath, err := mfa.outputPath(cmd)
|
||||||
|
|
||||||
// Phase 1: Enumeration - collect paths and stat files
|
|
||||||
err := mfa.runEnumeratePhase(cmd, s)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
showProgress := cmd.Bool("progress")
|
opts, err := mfa.buildScannerOptions(cmd, outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
|
||||||
|
// Phase 1: Enumeration - collect paths and stat files
|
||||||
|
err = mfa.runEnumeratePhase(cmd, s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -229,7 +308,7 @@ func (mfa *CLIApp) generateManifestOperation(
|
|||||||
|
|
||||||
outFile, err := mfa.Fs.Create(tmpPath)
|
outFile, err := mfa.Fs.Create(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return 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
|
||||||
@@ -250,37 +329,21 @@ func (mfa *CLIApp) generateManifestOperation(
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Phase 2: Scan - read file contents and generate manifest
|
// Phase 2: Scan - read file contents and generate manifest
|
||||||
var (
|
err = mfa.runScanPhase(ctx, cmd, s, outFile)
|
||||||
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 fmt.Errorf("failed to generate manifest: %w", err)
|
return 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 fmt.Errorf("failed to close temp file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
err = mfa.Fs.Rename(tmpPath, outputPath)
|
err = mfa.Fs.Rename(tmpPath, outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
success = true
|
success = true
|
||||||
|
|||||||
@@ -19,20 +19,19 @@ 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 fmt.Errorf("list: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
rc, err := mfa.openManifestReader(ctx, pathOrURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list: %w", err)
|
return 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("list: failed to parse manifest: %w", err)
|
return fmt.Errorf("parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files := manifest.Files()
|
files := manifest.Files()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,17 +16,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.
|
// errHTTPStatus indicates an HTTP response with a non-OK status code. It is
|
||||||
//
|
// followed by the code, as in "unexpected HTTP status 404".
|
||||||
// Its text is the literal "HTTP" prefix of the rendered "HTTP <code>"
|
var errHTTPStatus = errors.New("unexpected HTTP status")
|
||||||
// message that mfer has always printed, so that wrapping it does not
|
|
||||||
// change any user-visible output. Match it with errors.Is; do not read
|
|
||||||
// its message.
|
|
||||||
var errHTTPStatus = errors.New("HTTP")
|
|
||||||
|
|
||||||
// errManifestTooLarge indicates a manifest download that passed
|
// errManifestTooLarge indicates a manifest download that passed
|
||||||
// CLIApp.maxManifestSize.
|
// CLIApp.maxManifestSize.
|
||||||
var errManifestTooLarge = errors.New("manifest exceeds maximum allowed size")
|
var errManifestTooLarge = errors.New("file exceeds maximum allowed size")
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -40,20 +37,22 @@ 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("failed to fetch %s: %w", pathOrURL, err)
|
return nil, fmt.Errorf("download manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
return nil, fmt.Errorf("download manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to fetch %s: %w %d",
|
return nil, fmt.Errorf("download manifest %s: %w %d",
|
||||||
pathOrURL, errHTTPStatus, resp.StatusCode)
|
pathOrURL, errHTTPStatus, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +87,17 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+96
-57
@@ -6,12 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
"sneak.berlin/go/mfer/internal/log"
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
"sneak.berlin/go/mfer/mfer"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command and flag names shared across command definitions and tests.
|
// Command and flag names shared across command definitions and tests.
|
||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
cmdList = "list"
|
cmdList = "list"
|
||||||
cmdVersion = "version"
|
cmdVersion = "version"
|
||||||
|
|
||||||
|
flagBase = "base"
|
||||||
flagProgress = "progress"
|
flagProgress = "progress"
|
||||||
flagTimeout = "timeout"
|
flagTimeout = "timeout"
|
||||||
flagDest = "dest"
|
flagDest = "dest"
|
||||||
@@ -47,13 +48,16 @@ func manifestTempPath(out string) string {
|
|||||||
// errUnknownCommand indicates an unrecognized command argument.
|
// errUnknownCommand indicates an unrecognized command argument.
|
||||||
var errUnknownCommand = errors.New("unknown command")
|
var errUnknownCommand = errors.New("unknown command")
|
||||||
|
|
||||||
|
// errUnexpectedArgument indicates a second argument to a command that takes
|
||||||
|
// one.
|
||||||
|
var errUnexpectedArgument = errors.New("unexpected argument")
|
||||||
|
|
||||||
// CLIApp is the main CLI application container. It holds configuration,
|
// CLIApp is the main CLI application container. It holds configuration,
|
||||||
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
||||||
//
|
//
|
||||||
//nolint:revive // established name used throughout the codebase and tests
|
//nolint:revive // established name used throughout the codebase and tests
|
||||||
type CLIApp struct {
|
type CLIApp struct {
|
||||||
appname string
|
appname string
|
||||||
version string
|
|
||||||
gitrev string
|
gitrev string
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
exitCode int
|
||||||
@@ -82,13 +86,26 @@ const banner = `
|
|||||||
\ \:\ \ \:\ \ \::/ \ \:\
|
\ \:\ \ \:\ \ \::/ \ \:\
|
||||||
\__\/ \__\/ \__\/ \__\/`
|
\__\/ \__\/ \__\/ \__\/`
|
||||||
|
|
||||||
// VersionString returns the version and git revision formatted for display.
|
// VersionString returns the version mfer reports: the revision the build
|
||||||
|
// stamped into main.Gitrev or, when it stamped none, as with `go install`,
|
||||||
|
// the main module's version from the binary's build information.
|
||||||
func (mfa *CLIApp) VersionString() string {
|
func (mfa *CLIApp) VersionString() string {
|
||||||
if mfa.gitrev != "" {
|
if mfa.gitrev != "" {
|
||||||
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
return mfa.gitrev
|
||||||
}
|
}
|
||||||
|
|
||||||
return mfer.Version
|
return moduleVersion(debug.ReadBuildInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
// moduleVersion returns the main module's version from the binary's build
|
||||||
|
// information, or "unknown" when the binary has none or it holds no version,
|
||||||
|
// as in a build with GO111MODULE=off.
|
||||||
|
func moduleVersion(info *debug.BuildInfo, ok bool) string {
|
||||||
|
if !ok || info.Main.Version == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Main.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// printVersion writes the version line shared by the --version flag and the
|
// printVersion writes the version line shared by the --version flag and the
|
||||||
@@ -101,8 +118,7 @@ func (mfa *CLIApp) printBanner() {
|
|||||||
if log.GetLevel() <= log.InfoLevel {
|
if log.GetLevel() <= log.InfoLevel {
|
||||||
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
||||||
_, _ = fmt.Fprintf(mfa.Stdout,
|
_, _ = fmt.Fprintf(mfa.Stdout,
|
||||||
" mfer by @sneak: v%s released %s\n",
|
" mfer by @sneak: %s\n", mfa.VersionString())
|
||||||
mfer.Version, mfer.ReleaseDate)
|
|
||||||
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,13 +169,25 @@ func commonFlags() []cli.Flag {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopOnFirstArg returns the StopOnNthArg setting every command uses: flags
|
// refuseExtraArgs is the ArgValidator of the commands that take one
|
||||||
// are read only before the command's first argument, and everything after it
|
// argument: it refuses a second one, naming it.
|
||||||
// is an argument, as with urfave/cli v2. So `mfer gen d -v` names a path "-v".
|
func refuseExtraArgs(_ context.Context, cmd *cli.Command) error {
|
||||||
func stopOnFirstArg() *int {
|
if cmd.Args().Len() > 1 {
|
||||||
n := 1
|
return fmt.Errorf("%w %q", errUnexpectedArgument, cmd.Args().Get(1))
|
||||||
|
}
|
||||||
|
|
||||||
return &n
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showCommandHelp is the CommandNotFound of the commands that take a path or
|
||||||
|
// URL. Those commands set HideHelpCommand, so that a path or URL named help or
|
||||||
|
// h is an argument rather than urfave/cli's help command; each sets it itself,
|
||||||
|
// because on the root it would also remove `mfer help`. With --help,
|
||||||
|
// urfave/cli looks the command's first argument up as a help topic, so
|
||||||
|
// `check DIR --help` asks for a topic DIR and finds none; this prints the
|
||||||
|
// command's own help instead, as `check --help` does.
|
||||||
|
func showCommandHelp(ctx context.Context, cmd *cli.Command, _ string) {
|
||||||
|
_ = cli.ShowCommandHelp(ctx, cmd.Root(), cmd.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireSignatureFlag returns the --require-signature flag taken by the
|
// requireSignatureFlag returns the --require-signature flag taken by the
|
||||||
@@ -168,7 +196,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 specified GPG key ID",
|
Usage: "Require manifest to be signed by the OpenPGP key with this fingerprint",
|
||||||
Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"),
|
Sources: cli.EnvVars("MFER_REQUIRE_SIGNATURE"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,11 +213,12 @@ func includePermissionsFlag() *cli.BoolFlag {
|
|||||||
|
|
||||||
func (mfa *CLIApp) generateCommand() *cli.Command {
|
func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdGenerate,
|
Name: cmdGenerate,
|
||||||
Aliases: []string{"gen"},
|
Aliases: []string{"gen"},
|
||||||
Usage: "Generate manifest file",
|
Usage: "Generate manifest file",
|
||||||
ArgsUsage: "[path ...]",
|
ArgsUsage: "[path ...]",
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
HideHelpCommand: true,
|
||||||
|
CommandNotFound: showCommandHelp,
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
mfa.setVerbosity(cmd)
|
mfa.setVerbosity(cmd)
|
||||||
mfa.printBanner()
|
mfa.printBanner()
|
||||||
@@ -210,9 +239,10 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
Value: defaultManifestName,
|
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Usage: "Specify output filename",
|
Usage: "File to write the manifest to (default: index.mf in " +
|
||||||
|
"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",
|
||||||
@@ -227,7 +257,7 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
|||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "sign-key",
|
Name: "sign-key",
|
||||||
Aliases: []string{"s"},
|
Aliases: []string{"s"},
|
||||||
Usage: "GPG key ID to sign the manifest with",
|
Usage: "OpenPGP secret key file to sign the manifest with",
|
||||||
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
@@ -247,10 +277,12 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
|||||||
|
|
||||||
func (mfa *CLIApp) checkCommand() *cli.Command {
|
func (mfa *CLIApp) checkCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdCheck,
|
Name: cmdCheck,
|
||||||
Usage: "Validate files using manifest file",
|
Usage: "Validate files using manifest file",
|
||||||
ArgsUsage: manifestArgsUsage,
|
ArgsUsage: manifestArgsUsage,
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
ArgValidator: refuseExtraArgs,
|
||||||
|
HideHelpCommand: true,
|
||||||
|
CommandNotFound: showCommandHelp,
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
mfa.setVerbosity(cmd)
|
mfa.setVerbosity(cmd)
|
||||||
mfa.printBanner()
|
mfa.printBanner()
|
||||||
@@ -259,10 +291,11 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
Flags: append(commonFlags(),
|
Flags: append(commonFlags(),
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "base",
|
Name: flagBase,
|
||||||
Aliases: []string{"b"},
|
Aliases: []string{"b"},
|
||||||
Value: ".",
|
Usage: "Base directory for resolving relative paths from manifest " +
|
||||||
Usage: "Base directory for resolving relative paths from manifest",
|
"(by default the directory holding the manifest, or the " +
|
||||||
|
"current directory for a manifest URL)",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: flagProgress,
|
Name: flagProgress,
|
||||||
@@ -280,10 +313,12 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
|
|||||||
|
|
||||||
func (mfa *CLIApp) freshenCommand() *cli.Command {
|
func (mfa *CLIApp) freshenCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdFreshen,
|
Name: cmdFreshen,
|
||||||
Usage: "Update manifest with changed, new, and removed files",
|
Usage: "Update manifest with changed, new, and removed files",
|
||||||
ArgsUsage: manifestArgsUsage,
|
ArgsUsage: manifestArgsUsage,
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
ArgValidator: refuseExtraArgs,
|
||||||
|
HideHelpCommand: true,
|
||||||
|
CommandNotFound: showCommandHelp,
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
mfa.setVerbosity(cmd)
|
mfa.setVerbosity(cmd)
|
||||||
mfa.printBanner()
|
mfa.printBanner()
|
||||||
@@ -292,10 +327,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
Flags: append(commonFlags(),
|
Flags: append(commonFlags(),
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "base",
|
Name: flagBase,
|
||||||
Aliases: []string{"b"},
|
Aliases: []string{"b"},
|
||||||
Value: ".",
|
Usage: "Base directory for resolving relative paths " +
|
||||||
Usage: "Base directory for resolving relative paths",
|
"(by default the directory holding the manifest)",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "follow-symlinks",
|
Name: "follow-symlinks",
|
||||||
@@ -316,7 +351,7 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
|||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "sign-key",
|
Name: "sign-key",
|
||||||
Aliases: []string{"s"},
|
Aliases: []string{"s"},
|
||||||
Usage: "GPG key ID to sign the manifest with",
|
Usage: "OpenPGP secret key file to sign the manifest with",
|
||||||
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
Sources: cli.EnvVars("MFER_SIGN_KEY"),
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
@@ -331,10 +366,12 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
|||||||
|
|
||||||
func (mfa *CLIApp) exportCommand() *cli.Command {
|
func (mfa *CLIApp) exportCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdExport,
|
Name: cmdExport,
|
||||||
Usage: "Export manifest contents as JSON",
|
Usage: "Export manifest contents as JSON",
|
||||||
ArgsUsage: "[manifest file or URL]",
|
ArgsUsage: "[manifest file or URL]",
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
ArgValidator: refuseExtraArgs,
|
||||||
|
HideHelpCommand: true,
|
||||||
|
CommandNotFound: showCommandHelp,
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
mfa.setVerbosity(cmd)
|
mfa.setVerbosity(cmd)
|
||||||
|
|
||||||
@@ -345,9 +382,8 @@ func (mfa *CLIApp) exportCommand() *cli.Command {
|
|||||||
|
|
||||||
func (mfa *CLIApp) versionCommand() *cli.Command {
|
func (mfa *CLIApp) versionCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdVersion,
|
Name: cmdVersion,
|
||||||
Usage: "Show version",
|
Usage: "Show version",
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(context.Context, *cli.Command) error {
|
Action: func(context.Context, *cli.Command) error {
|
||||||
mfa.printVersion()
|
mfa.printVersion()
|
||||||
|
|
||||||
@@ -358,12 +394,14 @@ func (mfa *CLIApp) versionCommand() *cli.Command {
|
|||||||
|
|
||||||
func (mfa *CLIApp) listCommand() *cli.Command {
|
func (mfa *CLIApp) listCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdList,
|
Name: cmdList,
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Usage: "List files in manifest",
|
Usage: "List files in manifest",
|
||||||
ArgsUsage: manifestArgsUsage,
|
ArgsUsage: manifestArgsUsage,
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
ArgValidator: refuseExtraArgs,
|
||||||
Action: mfa.listManifestOperation,
|
HideHelpCommand: true,
|
||||||
|
CommandNotFound: showCommandHelp,
|
||||||
|
Action: mfa.listManifestOperation,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "long",
|
Name: "long",
|
||||||
@@ -380,10 +418,12 @@ func (mfa *CLIApp) listCommand() *cli.Command {
|
|||||||
|
|
||||||
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: cmdFetch,
|
Name: cmdFetch,
|
||||||
Usage: "fetch manifest and referenced files",
|
Usage: "fetch manifest and referenced files",
|
||||||
ArgsUsage: "URL",
|
ArgsUsage: "URL",
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
ArgValidator: refuseExtraArgs,
|
||||||
|
HideHelpCommand: true,
|
||||||
|
CommandNotFound: showCommandHelp,
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
mfa.setVerbosity(cmd)
|
mfa.setVerbosity(cmd)
|
||||||
mfa.printBanner()
|
mfa.printBanner()
|
||||||
@@ -442,9 +482,8 @@ func (mfa *CLIApp) run(args []string) {
|
|||||||
Writer: mfa.Stdout,
|
Writer: mfa.Stdout,
|
||||||
// v3 writes its "Incorrect Usage" line to ErrWriter; v2 wrote it to
|
// v3 writes its "Incorrect Usage" line to ErrWriter; v2 wrote it to
|
||||||
// stdout, before the help, so it stays on stdout.
|
// stdout, before the help, so it stays on stdout.
|
||||||
ErrWriter: mfa.Stdout,
|
ErrWriter: mfa.Stdout,
|
||||||
Flags: commonFlags(),
|
Flags: commonFlags(),
|
||||||
StopOnNthArg: stopOnFirstArg(),
|
|
||||||
Action: func(_ context.Context, cmd *cli.Command) error {
|
Action: func(_ context.Context, cmd *cli.Command) error {
|
||||||
if cmd.Args().Len() > 0 {
|
if cmd.Args().Len() > 0 {
|
||||||
return fmt.Errorf("%w %q", errUnknownCommand, cmd.Args().First())
|
return fmt.Errorf("%w %q", errUnknownCommand, cmd.Args().First())
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box tests exercise unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// testPipe is the name of the named pipe the pipe tests make in a tree.
|
||||||
|
testPipe = "pipe"
|
||||||
|
|
||||||
|
// pipeDeadline is how long a test lets a run go before it opens the
|
||||||
|
// test's named pipes for writing.
|
||||||
|
pipeDeadline = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// makePipe makes a named pipe at path. Opening a named pipe for reading
|
||||||
|
// blocks until it is opened for writing, so once pipeDeadline has passed,
|
||||||
|
// unless the test has ended, makePipe opens it for writing and closes it
|
||||||
|
// again. A run that wrongly reads the pipe then ends, and the test fails on
|
||||||
|
// what it reports instead of hanging. That lets one read through, so each
|
||||||
|
// path a wrong run would read gets a pipe of its own.
|
||||||
|
func makePipe(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, syscall.Mkfifo(path, 0o600))
|
||||||
|
|
||||||
|
timer := time.AfterFunc(pipeDeadline, func() {
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY, 0) //nolint:gosec // test-controlled path
|
||||||
|
if err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() { timer.Stop() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateSkipsPipe runs gen -v on a tree holding a named pipe: the
|
||||||
|
// manifest lists the regular file only, and gen names the pipe it skipped.
|
||||||
|
func TestGenerateSkipsPipe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestFile(t, fs, filepath.Join(dir, testFileTxt), "regular")
|
||||||
|
makePipe(t, filepath.Join(dir, testPipe))
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-v", dir}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.Equal(t, []string{testFileTxt},
|
||||||
|
manifestPaths(t, fs, filepath.Join(dir, defaultManifestName)))
|
||||||
|
assert.Contains(t, testStderr(t, opts), "skipping pipe: not a regular file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenSkipsPipes adds a named pipe to a tree after gen made its
|
||||||
|
// manifest, and a symlink to another pipe. freshen -v --follow-symlinks
|
||||||
|
// leaves the manifest listing the regular file only, and names both entries
|
||||||
|
// it skipped.
|
||||||
|
func TestFreshenSkipsPipes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
root, manifestPath := setupFreshenDir(t, fs,
|
||||||
|
map[string]string{testFileTxt: "regular"})
|
||||||
|
makePipe(t, filepath.Join(root, testPipe))
|
||||||
|
|
||||||
|
// The symlink points outside the tree, so no pipe is read twice.
|
||||||
|
target := filepath.Join(t.TempDir(), testPipe)
|
||||||
|
makePipe(t, target)
|
||||||
|
require.NoError(t, os.Symlink(target, filepath.Join(root, testLink)))
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdFreshen, "-v", "--follow-symlinks",
|
||||||
|
testFlagBase, root, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, manifestPath))
|
||||||
|
|
||||||
|
stderr := testStderr(t, opts)
|
||||||
|
assert.Contains(t, stderr, "skipping pipe: not a regular file")
|
||||||
|
assert.Contains(t, stderr, "skipping link: not a regular file")
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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, which must be able to sign. The passphrase of a protected
|
||||||
|
// key comes from MFER_SIGN_KEY_PASSPHRASE, or else from the terminal on
|
||||||
|
// stdin, and must unlock the key.
|
||||||
|
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 {
|
||||||
|
opts.Passphrase, err = mfa.readPassphrase(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gen and freshen read the signing options before any file, so a key
|
||||||
|
// that cannot sign, or a wrong passphrase, stops them before they hash
|
||||||
|
// anything.
|
||||||
|
err = mfer.CheckSigningKey(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
//nolint:testpackage // white-box tests exercise unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testFlagSignKey = "--sign-key"
|
||||||
|
testKeyFile = "/key.asc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, nil)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(root, defaultManifestName)
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
|
||||||
|
"-o", manifestPath, root,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenSignedManifestNeedsSignKey runs freshen on a signed manifest
|
||||||
|
// after a file is added. Without --sign-key it must fail, naming the
|
||||||
|
// signer and --sign-key, and leave the manifest as it was; with --sign-key
|
||||||
|
// it must rewrite the manifest, signed.
|
||||||
|
func TestFreshenSignedManifestNeedsSignKey(t *testing.T) {
|
||||||
|
t.Setenv(envSignKeyPassphrase, "")
|
||||||
|
|
||||||
|
secretKey, fingerprint := testSecretKey(t, nil, nil)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(root, defaultManifestName)
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
|
||||||
|
"-o", manifestPath, root,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
before, err := afero.ReadFile(fs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
||||||
|
|
||||||
|
opts = testOpts([]string{testApp, cmdFreshen, "-q", manifestPath}, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts))
|
||||||
|
assert.Contains(t, testStderr(t, opts), fingerprint)
|
||||||
|
assert.Contains(t, testStderr(t, opts), testFlagSignKey)
|
||||||
|
|
||||||
|
after, err := afero.ReadFile(fs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, before, after)
|
||||||
|
|
||||||
|
opts = testOpts([]string{
|
||||||
|
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
opts = testOpts([]string{
|
||||||
|
testApp, cmdCheck, "-q",
|
||||||
|
"--" + flagRequireSignature, fingerprint, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
assert.Len(t, manifestFiles(t, fs, manifestPath), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenSignsUnchangedTree runs freshen with --sign-key on a tree that
|
||||||
|
// has not changed since gen made its unsigned manifest. freshen must
|
||||||
|
// rewrite the manifest signed by that key. Run again with the same key it
|
||||||
|
// must leave the manifest as it is; with another key it must rewrite it
|
||||||
|
// signed by that one.
|
||||||
|
func TestFreshenSignsUnchangedTree(t *testing.T) {
|
||||||
|
t.Setenv(envSignKeyPassphrase, "")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
_, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "hello"})
|
||||||
|
|
||||||
|
newKeyFile := func() (string, string) {
|
||||||
|
secretKey, fingerprint := testSecretKey(t, nil, nil)
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
||||||
|
|
||||||
|
return keyFile, fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
freshenAndCheckSignedBy := func(keyFile, fingerprint string) {
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
|
||||||
|
opts = testOpts([]string{
|
||||||
|
testApp, cmdCheck, "-q",
|
||||||
|
"--" + flagRequireSignature, fingerprint, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
keyFile, fingerprint := newKeyFile()
|
||||||
|
freshenAndCheckSignedBy(keyFile, fingerprint)
|
||||||
|
|
||||||
|
before, err := afero.ReadFile(fs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
freshenAndCheckSignedBy(keyFile, fingerprint)
|
||||||
|
|
||||||
|
after, err := afero.ReadFile(fs, manifestPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, before, after, "manifest already signed by the key was rewritten")
|
||||||
|
|
||||||
|
freshenAndCheckSignedBy(newKeyFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key,
|
||||||
|
// with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must
|
||||||
|
// fail, naming the variable, and write no manifest.
|
||||||
|
func TestSignWithProtectedKeyNeedsPassphrase(t *testing.T) {
|
||||||
|
t.Setenv(envSignKeyPassphrase, "")
|
||||||
|
|
||||||
|
secretKey, _ := testSecretKey(t, []byte("secret"), nil)
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600))
|
||||||
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
|
writeTestFile(t, fs, testFile1, "hello")
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile,
|
||||||
|
"-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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignWithKeyThatCannotSignFailsFirst runs gen on a directory and
|
||||||
|
// freshen on a manifest, neither of which exists, with keys that cannot
|
||||||
|
// sign: a protected key with a wrong MFER_SIGN_KEY_PASSPHRASE, a key that
|
||||||
|
// expired in 2020, and a version 6 key. Each run must fail on the key: it
|
||||||
|
// checks the key before it reads any file, so a missing file goes
|
||||||
|
// unnoticed.
|
||||||
|
func TestSignWithKeyThatCannotSignFailsFirst(t *testing.T) {
|
||||||
|
t.Setenv(envSignKeyPassphrase, "wrong")
|
||||||
|
|
||||||
|
wrongPassphrase, _ := testSecretKey(t, []byte("right"), nil)
|
||||||
|
|
||||||
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
expired, _ := testSecretKey(t, nil, &packet.Config{
|
||||||
|
Algorithm: packet.PubKeyAlgoEdDSA,
|
||||||
|
Time: func() time.Time { return made },
|
||||||
|
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
|
||||||
|
})
|
||||||
|
|
||||||
|
version6, _ := testSecretKey(t, nil, &packet.Config{
|
||||||
|
Algorithm: packet.PubKeyAlgoEd25519,
|
||||||
|
V6Keys: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
for want, secretKey := range map[string][]byte{
|
||||||
|
"unlock signing key": wrongPassphrase,
|
||||||
|
"signing key cannot sign": expired,
|
||||||
|
"signing key must be an OpenPGP version 4 key": version6,
|
||||||
|
} {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600))
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{
|
||||||
|
testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile,
|
||||||
|
"-o", testMF, "/missing",
|
||||||
|
},
|
||||||
|
{testApp, cmdFreshen, "-q", testFlagSignKey, testKeyFile, "/missing.mf"},
|
||||||
|
} {
|
||||||
|
opts := testOpts(args, fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts), args[1], want)
|
||||||
|
assert.Contains(t, testStderr(t, opts), testKeyFile+": "+want, args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenAsksForPassphraseOnTerminal runs gen with a protected key, no
|
||||||
|
// MFER_SIGN_KEY_PASSPHRASE, and a terminal as stdin and stderr. gen must
|
||||||
|
// ask for the passphrase on stderr, and sign with what is typed after the
|
||||||
|
// prompt.
|
||||||
|
func TestGenAsksForPassphraseOnTerminal(t *testing.T) {
|
||||||
|
t.Setenv(envSignKeyPassphrase, "")
|
||||||
|
|
||||||
|
secretKey, fingerprint := testSecretKey(t, []byte("passphrase"), nil)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(root, defaultManifestName)
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||||
|
|
||||||
|
terminal, tty, err := pty.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = terminal.Close() })
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
|
||||||
|
"-o", manifestPath, root,
|
||||||
|
}, fs)
|
||||||
|
opts.Stdin = tty
|
||||||
|
opts.Stderr = tty
|
||||||
|
|
||||||
|
exitCode := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
exitCode <- runCLI(opts)
|
||||||
|
|
||||||
|
// Once gen has ended, reading the terminal fails instead of
|
||||||
|
// waiting for a prompt that will not come.
|
||||||
|
_ = tty.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
prompt := "Passphrase for " + keyFile + ": "
|
||||||
|
output := bufio.NewReader(terminal)
|
||||||
|
written := ""
|
||||||
|
|
||||||
|
for !strings.HasSuffix(written, prompt) {
|
||||||
|
b, err := output.ReadByte()
|
||||||
|
require.NoError(t, err, "gen wrote %q and no prompt", written)
|
||||||
|
|
||||||
|
written += string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = terminal.WriteString("passphrase\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
code := <-exitCode
|
||||||
|
rest, _ := io.ReadAll(output)
|
||||||
|
require.Equal(t, 0, code, "gen wrote %q", rest)
|
||||||
|
|
||||||
|
check := testOpts([]string{
|
||||||
|
testApp, cmdCheck, "-q",
|
||||||
|
"--" + flagRequireSignature, fingerprint, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(check), testStderr(t, check))
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEmptySignKeyRefused runs gen and freshen, each with flags, on a
|
||||||
|
// directory holding a file and an unsigned manifest. Each must fail,
|
||||||
|
// naming --sign-key, and leave the manifest as it was.
|
||||||
|
func assertEmptySignKeyRefused(t *testing.T, flags ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
manifest := manifestOf(t, map[string][]byte{testFileTxt: []byte("listed")})
|
||||||
|
|
||||||
|
for command, rest := range map[string][]string{
|
||||||
|
cmdGenerate: {"-q", "-f", "-o", testMF, testDir},
|
||||||
|
cmdFreshen: {"-q", testMF},
|
||||||
|
} {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
|
writeTestFile(t, fs, testFile1, "hello")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat([]string{testApp, command}, flags, rest), fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts), command)
|
||||||
|
assert.Contains(t, testStderr(t, opts), errEmptySignKey.Error(), command)
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(fs, testMF)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, manifest, content, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenAndFreshenRefuseEmptySignKey runs gen and freshen with --sign-key
|
||||||
|
// given an empty value, as an unset shell variable gives it.
|
||||||
|
func TestGenAndFreshenRefuseEmptySignKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertEmptySignKeyRefused(t, testFlagSignKey, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenAndFreshenRefuseEmptySignKeyFromEnvironment runs gen and freshen
|
||||||
|
// with MFER_SIGN_KEY set to an empty string.
|
||||||
|
func TestGenAndFreshenRefuseEmptySignKeyFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("MFER_SIGN_KEY", "")
|
||||||
|
|
||||||
|
assertEmptySignKeyRefused(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEmptyFingerprintRefused runs check on an unsigned manifest, and
|
||||||
|
// fetch of that manifest from a server, each with flags. Each must refuse
|
||||||
|
// the empty fingerprint as malformed, and fetch must request nothing.
|
||||||
|
func assertEmptyFingerprintRefused(t *testing.T, flags ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const want = "invalid fingerprint: must be exactly 40 hex characters, got 0"
|
||||||
|
|
||||||
|
files := map[string][]byte{testFileTxt: []byte("listed")}
|
||||||
|
manifest := manifestOf(t, files)
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
|
writeTestFile(t, fs, filepath.Join(testDir, testFileTxt), "listed")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdCheck, "-q"}, flags, []string{testMF},
|
||||||
|
), fs)
|
||||||
|
assert.Equal(t, 1, runCLI(opts), cmdCheck)
|
||||||
|
assert.Contains(t, testStderr(t, opts), want, cmdCheck)
|
||||||
|
|
||||||
|
var requests atomic.Int32
|
||||||
|
|
||||||
|
tree := fetchTestHandler(manifest, files)
|
||||||
|
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
tree.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
opts = testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdFetch, "-q", "--" + flagDest, t.TempDir()},
|
||||||
|
flags, []string{server.URL},
|
||||||
|
), afero.NewOsFs())
|
||||||
|
assert.Equal(t, 1, runCLI(opts), cmdFetch)
|
||||||
|
assert.Contains(t, testStderr(t, opts), want, cmdFetch)
|
||||||
|
assert.Zero(t, requests.Load(), "fetch made a request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckAndFetchRefuseEmptyRequireSignature runs check and fetch with
|
||||||
|
// --require-signature given an empty value, as an unset shell variable
|
||||||
|
// gives it.
|
||||||
|
func TestCheckAndFetchRefuseEmptyRequireSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertEmptyFingerprintRefused(t, "--"+flagRequireSignature, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment runs check
|
||||||
|
// and fetch with MFER_REQUIRE_SIGNATURE set to an empty string.
|
||||||
|
func TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("MFER_REQUIRE_SIGNATURE", "")
|
||||||
|
|
||||||
|
assertEmptyFingerprintRefused(t)
|
||||||
|
}
|
||||||
+36
-20
@@ -34,10 +34,12 @@ var (
|
|||||||
errPathAbsolute = errors.New("is absolute; must be relative")
|
errPathAbsolute = errors.New("is absolute; must be relative")
|
||||||
errPathEmptySegment = errors.New("contains empty segment")
|
errPathEmptySegment = errors.New("contains empty segment")
|
||||||
errPathDotDot = errors.New("contains '..' segment")
|
errPathDotDot = errors.New("contains '..' segment")
|
||||||
|
errPathDot = errors.New("contains '.' segment")
|
||||||
errSizeMismatch = errors.New("size mismatch")
|
errSizeMismatch = errors.New("size mismatch")
|
||||||
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:
|
||||||
@@ -45,6 +47,7 @@ var (
|
|||||||
// - Must use forward slashes only (no backslashes)
|
// - Must use forward slashes only (no backslashes)
|
||||||
// - Must be relative (no leading /)
|
// - Must be relative (no leading /)
|
||||||
// - Must not contain ".." segments
|
// - Must not contain ".." segments
|
||||||
|
// - Must not contain "." segments
|
||||||
// - Must not contain empty segments (no "//")
|
// - Must not contain empty segments (no "//")
|
||||||
// - Must not be empty
|
// - Must not be empty
|
||||||
func ValidatePath(p string) error {
|
func ValidatePath(p string) error {
|
||||||
@@ -72,6 +75,10 @@ func ValidatePath(p string) error {
|
|||||||
if seg == ".." {
|
if seg == ".." {
|
||||||
return fmt.Errorf("path %q %w", p, errPathDotDot)
|
return fmt.Errorf("path %q %w", p, errPathDotDot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if seg == "." {
|
||||||
|
return fmt.Errorf("path %q %w", p, errPathDot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -115,6 +122,7 @@ 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
|
||||||
@@ -125,6 +133,7 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +147,7 @@ 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.
|
||||||
@@ -204,11 +214,7 @@ func (b *Builder) AddFile(
|
|||||||
Mode: uint32(mode.Perm()),
|
Mode: uint32(mode.Perm()),
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
return totalRead, b.addEntry(entry)
|
||||||
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,8 +240,9 @@ 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, size is negative, or hash is not a
|
// Returns an error if path is invalid or already added, size is negative,
|
||||||
// multihash with a digest of at least 32 bytes, as long as SHA-256's.
|
// or hash is not a multihash with a digest of at least 32 bytes, as long
|
||||||
|
// as SHA-256's.
|
||||||
func (b *Builder) AddFileWithHash(
|
func (b *Builder) AddFileWithHash(
|
||||||
path RelFilePath,
|
path RelFilePath,
|
||||||
size FileSize,
|
size FileSize,
|
||||||
@@ -245,7 +252,7 @@ func (b *Builder) AddFileWithHash(
|
|||||||
) error {
|
) error {
|
||||||
err := ValidatePath(string(path))
|
err := ValidatePath(string(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if size < 0 {
|
if size < 0 {
|
||||||
@@ -277,11 +284,7 @@ func (b *Builder) AddFileWithHash(
|
|||||||
Mode: uint32(mode.Perm()),
|
Mode: uint32(mode.Perm()),
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
return b.addEntry(entry)
|
||||||
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.
|
||||||
@@ -293,7 +296,7 @@ func (b *Builder) SetIncludeTimestamps(include bool) {
|
|||||||
b.includeTimestamps = include
|
b.includeTimestamps = include
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSigningOptions sets the GPG signing options for the manifest.
|
// SetSigningOptions sets the key the manifest is signed with.
|
||||||
// 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()
|
||||||
@@ -302,8 +305,8 @@ func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
|||||||
b.signingOptions = opts
|
b.signingOptions = opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build finalizes the manifest and writes it to the writer. ctx bounds the
|
// Build finalizes the manifest and writes it to the writer. When signing
|
||||||
// gpg runs that sign the manifest when signing options are set.
|
// options are set, it does not sign once ctx has ended.
|
||||||
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()
|
||||||
@@ -332,20 +335,33 @@ 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 fmt.Errorf("build: generate outer: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate final output
|
// Generate final output
|
||||||
err = m.generate(ctx)
|
err = m.generate(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build: generate: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to output
|
// Write to output
|
||||||
_, err = w.Write(m.output.Bytes())
|
_, err = w.Write(m.output.Bytes())
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build: write output: %w", err)
|
return 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,32 @@ 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()
|
||||||
|
|
||||||
@@ -260,6 +286,9 @@ func TestValidatePath(t *testing.T) {
|
|||||||
{"has/../traversal", "dot-dot segment"},
|
{"has/../traversal", "dot-dot segment"},
|
||||||
{"has//double", "empty segment"},
|
{"has//double", "empty segment"},
|
||||||
{"..", "just dot-dot"},
|
{"..", "just dot-dot"},
|
||||||
|
{"./a.txt", "leading dot segment"},
|
||||||
|
{"has/./dot", "interior dot segment"},
|
||||||
|
{".", "just dot"},
|
||||||
{string([]byte{0xff, 0xfe}), "invalid UTF-8"},
|
{string([]byte{0xff, 0xfe}), "invalid UTF-8"},
|
||||||
}
|
}
|
||||||
for _, tt := range invalid {
|
for _, tt := range invalid {
|
||||||
|
|||||||
+29
-18
@@ -15,7 +15,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errNoSigningPubKey = errors.New("manifest has no signing public key")
|
|
||||||
errManifestPathEmpty = errors.New("manifest path cannot be empty")
|
errManifestPathEmpty = errors.New("manifest path cannot be empty")
|
||||||
errBasePathEmpty = errors.New("base path cannot be empty")
|
errBasePathEmpty = errors.New("base path cannot be empty")
|
||||||
)
|
)
|
||||||
@@ -173,8 +172,14 @@ func (c *Checker) IsSigned() bool {
|
|||||||
return len(c.signature) > 0
|
return len(c.signature) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signer returns the signer fingerprint if the manifest is signed, nil otherwise.
|
// Signer returns the fingerprint of the key that made the manifest's
|
||||||
|
// signature, which loading the manifest checked, or nil if the manifest is
|
||||||
|
// not signed.
|
||||||
func (c *Checker) Signer() []byte {
|
func (c *Checker) Signer() []byte {
|
||||||
|
if !c.IsSigned() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return c.signer
|
return c.signer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,17 +189,6 @@ func (c *Checker) SigningPubKey() []byte {
|
|||||||
return c.signingPubKey
|
return c.signingPubKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
|
||||||
// temporary keyring and extracts its fingerprint. This validates the key and
|
|
||||||
// returns its actual fingerprint from the key material itself.
|
|
||||||
func (c *Checker) ExtractEmbeddedSigningKeyFP(ctx context.Context) (string, error) {
|
|
||||||
if len(c.signingPubKey) == 0 {
|
|
||||||
return "", errNoSigningPubKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return gpgExtractPubKeyFingerprint(ctx, c.signingPubKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check verifies all files against the manifest.
|
// Check verifies all files against the manifest.
|
||||||
// Results are sent to the results channel as files are checked.
|
// Results are sent to the results channel as files are checked.
|
||||||
// Progress updates are sent to the progress channel approximately once per second.
|
// Progress updates are sent to the progress channel approximately once per second.
|
||||||
@@ -270,11 +264,12 @@ func (c *Checker) Check(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindExtraFiles walks the filesystem and reports files not in the manifest,
|
// FindExtraFiles walks the filesystem and reports the regular files, and
|
||||||
// hidden files and directories included. The manifest file itself is not
|
// symlinks to them, that are not in the manifest, hidden files and
|
||||||
// reported. Anything the search cannot read, such as a directory that cannot
|
// directories included. The manifest file itself is not reported. Anything
|
||||||
// be listed, is reported with StatusError and the search goes on. Results are
|
// the search cannot read, such as a directory that cannot be listed, is
|
||||||
// sent to the results channel. The channel is closed when done.
|
// reported with StatusError and the search goes on. Results are sent to the
|
||||||
|
// results channel. The channel is closed when done.
|
||||||
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
||||||
if results != nil {
|
if results != nil {
|
||||||
defer close(results)
|
defer close(results)
|
||||||
@@ -333,6 +328,13 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gen lists regular files and symlinks to them only, so a named
|
||||||
|
// pipe, a socket, a device, a symlink to a directory and a broken
|
||||||
|
// symlink are never reported.
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Skip the manifest file itself, however its path is spelled
|
// Skip the manifest file itself, however its path is spelled
|
||||||
if os.SameFile(info, c.manifestInfo) {
|
if os.SameFile(info, c.manifestInfo) {
|
||||||
return nil
|
return nil
|
||||||
@@ -377,6 +379,15 @@ func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
|||||||
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opening a named pipe can block, and reading a device may never end.
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return Result{
|
||||||
|
Path: relPath,
|
||||||
|
Status: StatusError,
|
||||||
|
Message: "not a regular file",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check size
|
// Check size
|
||||||
if info.Size() != entry.GetSize() {
|
if info.Size() != entry.GetSize() {
|
||||||
*checkedBytes += FileSize(info.Size())
|
*checkedBytes += FileSize(info.Size())
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current mfer release version.
|
|
||||||
Version = "0.1.0"
|
|
||||||
|
|
||||||
// ReleaseDate is the date on which Version was released.
|
|
||||||
ReleaseDate = "2025-12-17"
|
|
||||||
|
|
||||||
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
||||||
// data (256 MiB). This prevents decompression bombs from consuming excessive
|
// data (256 MiB). This prevents decompression bombs from consuming excessive
|
||||||
// memory.
|
// memory.
|
||||||
|
|||||||
+45
-22
@@ -2,11 +2,11 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -23,12 +23,14 @@ 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("manifest 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("manifest contains invalid path")
|
errInvalidManifestPath = errors.New("invalid file entry")
|
||||||
errDecodedTooLarge = errors.New(
|
errEntryMissingHash = errors.New("file entry has no hash")
|
||||||
"manifest would take too much memory to decode")
|
errDecodedTooLarge = errors.New("too much memory needed")
|
||||||
|
errSignerNotSigningKey = errors.New(
|
||||||
|
"signer is not the fingerprint of the key that made the signature")
|
||||||
)
|
)
|
||||||
|
|
||||||
// validateUUID checks that the byte slice is the 16 bytes of a binary UUID.
|
// validateUUID checks that the byte slice is the 16 bytes of a binary UUID.
|
||||||
@@ -55,20 +57,22 @@ 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 UUID invalid: %w", err)
|
return fmt.Errorf("outer message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyOuterIntegrity checks the hash of the compressed payload and,
|
// verifyOuterIntegrity checks the hash of the compressed payload and, if a
|
||||||
// if a signature is present, verifies it against the embedded public key.
|
// signature is present, verifies it against the embedded public key, which
|
||||||
|
// must be one key, and checks that the signer field is that key's
|
||||||
|
// fingerprint.
|
||||||
func (m *manifest) verifyOuterIntegrity() error {
|
func (m *manifest) verifyOuterIntegrity() error {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
|
||||||
_, err := h.Write(m.pbOuter.GetInnerMessage())
|
_, err := h.Write(m.pbOuter.GetInnerMessage())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deserialize: hash write: %w", err)
|
return fmt.Errorf("hash inner message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sha256Hash := h.Sum(nil)
|
sha256Hash := h.Sum(nil)
|
||||||
@@ -86,20 +90,21 @@ func (m *manifest) verifyOuterIntegrity() error {
|
|||||||
|
|
||||||
sigString, err := m.signatureString()
|
sigString, err := m.signatureString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf("build signature string: %w", err)
|
||||||
"failed to generate signature string for verification: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading a manifest takes no context; gpgTimeout still bounds gpg.
|
signingKey, err := verifySignature(
|
||||||
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 fmt.Errorf("signature verification failed: %w", err)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.EqualFold(string(m.pbOuter.GetSigner()), signingKey) {
|
||||||
|
return fmt.Errorf("%w: signer %q, signing key %s",
|
||||||
|
errSignerNotSigningKey, m.pbOuter.GetSigner(), signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("signature verified successfully")
|
log.Infof("signature verified successfully")
|
||||||
@@ -125,7 +130,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("deserialize: zstd reader: %w", err)
|
return nil, fmt.Errorf("create decompressor: %w", err)
|
||||||
}
|
}
|
||||||
defer zr.Close()
|
defer zr.Close()
|
||||||
|
|
||||||
@@ -140,7 +145,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("deserialize: decompress: %w", err)
|
return nil, fmt.Errorf("decompress inner message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if int64(len(dat)) >= MaxDecompressedSize {
|
if int64(len(dat)) >= MaxDecompressedSize {
|
||||||
@@ -256,7 +261,7 @@ func (m *manifest) deserializeInner() error {
|
|||||||
|
|
||||||
err = checkDecodedSize(dat)
|
err = checkDecodedSize(dat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
return fmt.Errorf("unmarshal inner message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deserialize inner message
|
// Deserialize inner message
|
||||||
@@ -265,13 +270,17 @@ 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("deserialize: unmarshal inner: %w", err)
|
return fmt.Errorf("unmarshal inner message: %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 UUID invalid: %w", err)
|
return fmt.Errorf("inner message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify UUIDs match
|
// Verify UUIDs match
|
||||||
@@ -284,12 +293,26 @@ 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.
|
// check. A path listed twice is refused too: check would check the one
|
||||||
|
// file against both entries. So is an entry with no hash: there is
|
||||||
|
// nothing to check the file against.
|
||||||
|
seen := make(map[string]bool, len(m.pbInner.GetFiles()))
|
||||||
|
|
||||||
for _, f := range m.pbInner.GetFiles() {
|
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 len(f.GetHashes()) == 0 {
|
||||||
|
return fmt.Errorf("%w: %q", errEntryMissingHash, f.GetPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
|||||||
@@ -19,14 +19,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"uuid"
|
"uuid"
|
||||||
@@ -81,6 +80,8 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"parent traversal", "../escape"},
|
{"parent traversal", "../escape"},
|
||||||
{"interior traversal", "a/../../escape"},
|
{"interior traversal", "a/../../escape"},
|
||||||
|
{"leading dot segment", "./a.txt"},
|
||||||
|
{"interior dot segment", "dir/./a.txt"},
|
||||||
{"absolute path", "/etc/passwd"},
|
{"absolute path", "/etc/passwd"},
|
||||||
{"backslash path", `a\b`},
|
{"backslash path", `a\b`},
|
||||||
{"double slash", "a//b"},
|
{"double slash", "a//b"},
|
||||||
@@ -118,12 +119,79 @@ 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manifest holding a file entry with no hash is refused as it is loaded,
|
||||||
|
// naming the path: there is nothing to check the file against.
|
||||||
|
func TestDeserializeRefusesEntryWithNoHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
id := uuid.NewV4()
|
||||||
|
inner, err := proto.Marshal(&MFFile{
|
||||||
|
Version: MFFile_VERSION_ONE,
|
||||||
|
Files: []*MFFilePath{{Path: "x", Size: 1}},
|
||||||
|
Uuid: id[:],
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
||||||
|
require.ErrorIs(t, err, errEntryMissingHash)
|
||||||
|
require.EqualError(t, err, `file entry has no hash: "x"`)
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
// it 55 bytes, about 7.9 times: loaded. Each entry's path is its number,
|
||||||
|
// padded with zeros to that length, since a manifest lists a path only once.
|
||||||
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -139,22 +207,24 @@ 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, 102, protowire.BytesType) // MFFile.uuid
|
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version
|
||||||
|
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 range 1000 {
|
for i := 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)
|
||||||
}
|
}
|
||||||
@@ -179,10 +249,14 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
|
|||||||
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
||||||
entry = protowire.AppendString(entry, "a")
|
entry = protowire.AppendString(entry, "a")
|
||||||
|
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||||
|
entry = protowire.AppendBytes(entry, nil)
|
||||||
entry = append(entry, unknown...)
|
entry = append(entry, unknown...)
|
||||||
|
|
||||||
id := uuid.NewV4()
|
id := uuid.NewV4()
|
||||||
inner := protowire.AppendTag(nil, 101, protowire.BytesType) // MFFile.files
|
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version
|
||||||
|
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[:])
|
||||||
|
|||||||
@@ -3,12 +3,36 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"uuid"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// TestReadAtMost gives readAtMost exactly its maximum, which it must
|
||||||
// return whole, and twice its maximum, which it must refuse after reading
|
// return whole, and twice its maximum, which it must refuse after reading
|
||||||
// one byte past the maximum, and no more. NewManifestFromReader reads
|
// one byte past the maximum, and no more. NewManifestFromReader reads
|
||||||
@@ -28,6 +52,41 @@ func TestReadAtMost(t *testing.T) {
|
|||||||
_, err = readAtMost(input, maxSize)
|
_, err = readAtMost(input, maxSize)
|
||||||
require.ErrorIs(t, err, errManifestTooLarge)
|
require.ErrorIs(t, err, errManifestTooLarge)
|
||||||
require.EqualError(t, err,
|
require.EqualError(t, err,
|
||||||
"manifest exceeds maximum allowed size of 65536 bytes")
|
"file exceeds maximum allowed size of 65536 bytes")
|
||||||
assert.Equal(t, maxSize-1, input.Len(), "bytes left unread")
|
assert.Equal(t, maxSize-1, input.Len(), "bytes left unread")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFormatTestVector checks the test vector that docs/FORMAT.md shows: the
|
||||||
|
// SHA-256 of the whole file, so that any change to the file fails, then loads
|
||||||
|
// it, verifies its signature, and checks the canonical string and the signer
|
||||||
|
// the document gives for it.
|
||||||
|
func TestFormatTestVector(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
path = "testdata/test-vector.mf"
|
||||||
|
fileSHA256 = "5864187d671b2a55f8e015f858248f92c51956b549cf548b6bb54477c2407fd4"
|
||||||
|
signer = "5E962A37590D855B467D20294947A60319611E6F"
|
||||||
|
)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
require.Equal(t, fileSHA256, hex.EncodeToString(sum[:]))
|
||||||
|
|
||||||
|
m, err := NewManifestFromFile(&ManifestFromFileOptions{Path: path})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
canonicalString, err := m.signatureString()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ZNAVSRFG-92197fd532bc40908aa9f86e5518b56c-"+
|
||||||
|
"12202353553205f036a88cefee3d0d9049ccd8e68bb24011a0b462b5867f1fcc6352",
|
||||||
|
canonicalString)
|
||||||
|
|
||||||
|
keyFingerprint, err := verifySignature([]byte(canonicalString),
|
||||||
|
m.pbOuter.GetSignature(), m.pbOuter.GetSigningPubKey())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, signer, keyFingerprint)
|
||||||
|
assert.Equal(t, signer, string(m.pbOuter.GetSigner()))
|
||||||
|
}
|
||||||
|
|||||||
+17
-7
@@ -62,6 +62,12 @@ func TestValidatePathMessagesVerbatim(t *testing.T) {
|
|||||||
want: `path "a/../b" contains '..' segment`,
|
want: `path "a/../b" contains '..' segment`,
|
||||||
is: errPathDotDot,
|
is: errPathDotDot,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "dot segment",
|
||||||
|
path: "a/./b",
|
||||||
|
want: `path "a/./b" contains '.' segment`,
|
||||||
|
is: errPathDot,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -74,14 +80,18 @@ func TestValidatePathMessagesVerbatim(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSerializeInternalErrorMessagesVerbatim pins the two distinct
|
// TestSerializeInnerNotSetMessagesVerbatim pins the messages generate and
|
||||||
// "internal error" messages, which differ between generate and
|
// generateOuter return when the inner message is missing.
|
||||||
// generateOuter and have always done so.
|
func TestSerializeInnerNotSetMessagesVerbatim(t *testing.T) {
|
||||||
func TestSerializeInternalErrorMessagesVerbatim(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m := &manifest{}
|
m := &manifest{}
|
||||||
require.EqualError(t, m.generate(context.Background()),
|
|
||||||
"internal error: pbInner not set")
|
err := m.generate(context.Background())
|
||||||
require.EqualError(t, m.generateOuter(context.Background()), "internal error")
|
require.ErrorIs(t, err, errInnerNotSet)
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
-309
@@ -1,309 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
// gpg option names used from more than one call site.
|
|
||||||
gpgOptArmor = "--armor"
|
|
||||||
gpgOptHomedir = "--homedir"
|
|
||||||
gpgOptVerify = "--verify"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errGPGKeyNotFound = errors.New("gpg key not found")
|
|
||||||
errFingerprintNotFound = errors.New("fingerprint not found for key")
|
|
||||||
errImportedFPRNotFound = errors.New("fingerprint not found in imported key")
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// gpgSign creates a detached signature of the data using the specified key.
|
|
||||||
// Returns the armored detached signature.
|
|
||||||
func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
|
||||||
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
|
||||||
"--detach-sign",
|
|
||||||
gpgOptArmor,
|
|
||||||
"--local-user", string(keyID),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return stdout.Bytes(), 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
|
||||||
// and extracts its fingerprint. This verifies the key is valid and returns
|
|
||||||
// the actual fingerprint from the key material.
|
|
||||||
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
|
|
||||||
// Create temporary directory for GPG operations
|
|
||||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the public key into the temporary keyring
|
|
||||||
_, importStderr, err := runGPG(ctx, nil,
|
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List keys to get fingerprint
|
|
||||||
listStdout, listStderr, err := runGPG(ctx, nil,
|
|
||||||
"--homedir", tmpDir,
|
|
||||||
"--with-colons",
|
|
||||||
"--fingerprint",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"failed to list keys: %w: %s", err, listStderr.String(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fpr, ok := parseFingerprint(listStdout.String())
|
|
||||||
if !ok {
|
|
||||||
return "", errImportedFPRNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return fpr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// gpgVerify verifies a detached signature against data using the provided public key.
|
|
||||||
// It creates a temporary keyring to import the public key for verification.
|
|
||||||
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the public key into the temporary keyring
|
|
||||||
_, importStderr, err := runGPG(ctx, nil,
|
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the signature
|
|
||||||
_, verifyStderr, err := runGPG(ctx, nil,
|
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
|
||||||
sigFile, dataFile)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
// Generate a test key with no passphrase
|
|
||||||
keyParams := `%no-protection
|
|
||||||
Key-Type: RSA
|
|
||||||
Key-Length: 2048
|
|
||||||
Name-Real: MFER Test Key
|
|
||||||
Name-Email: test@mfer.test
|
|
||||||
Expire-Date: 0
|
|
||||||
%commit
|
|
||||||
`
|
|
||||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
|
||||||
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGPGSign(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)
|
|
||||||
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
|
|
||||||
err = gpgVerify(context.Background(), data, sig, pubKey)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
+19
-2
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errOuterNotSet = errors.New("pbOuter not set")
|
errOuterNotSet = errors.New("outer message 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")
|
||||||
)
|
)
|
||||||
@@ -47,6 +47,23 @@ func (m *manifest) Files() []*MFFilePath {
|
|||||||
return m.pbInner.GetFiles()
|
return m.pbInner.GetFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signer returns the fingerprint of the key that made a loaded manifest's
|
||||||
|
// signature, which loading the manifest checked, or nil if the manifest is
|
||||||
|
// not signed.
|
||||||
|
func (m *manifest) Signer() []byte {
|
||||||
|
if len(m.pbOuter.GetSignature()) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.pbOuter.GetSigner()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatedAt returns the createdAt timestamp of a loaded manifest, or nil
|
||||||
|
// if the manifest has none.
|
||||||
|
func (m *manifest) CreatedAt() *Timestamp {
|
||||||
|
return m.pbInner.GetCreatedAt()
|
||||||
|
}
|
||||||
|
|
||||||
// signatureString generates the canonical string used for signing/verification.
|
// signatureString generates the canonical string used for signing/verification.
|
||||||
// Format: MAGIC-UUID-MULTIHASH where UUID and multihash are hex-encoded.
|
// Format: MAGIC-UUID-MULTIHASH where UUID and multihash are hex-encoded.
|
||||||
// Requires pbOuter to be set with Uuid and Sha256 fields.
|
// Requires pbOuter to be set with Uuid and Sha256 fields.
|
||||||
@@ -65,7 +82,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("failed to encode multihash: %w", err)
|
return "", fmt.Errorf("encode multihash: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
uuidStr := hex.EncodeToString(m.pbOuter.GetUuid())
|
uuidStr := hex.EncodeToString(m.pbOuter.GetUuid())
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//nolint:testpackage // white-box tests exercise unexported internals
|
||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
"uuid"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSignatureString checks the canonical string for the UUID and sha256
|
||||||
|
// of the example in the Signature Scheme section of docs/FORMAT.md, which
|
||||||
|
// shows the same string.
|
||||||
|
func TestSignatureString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifestUUID := uuid.MustParse("b0fb6918-6298-443b-ada2-1b162f4dc0b3")
|
||||||
|
sha256Hash, err := hex.DecodeString(
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m := &manifest{pbOuter: &MFFileOuter{Uuid: manifestUUID[:], Sha256: sha256Hash}}
|
||||||
|
|
||||||
|
got, err := m.signatureString()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ZNAVSRFG-b0fb69186298443bada21b162f4dc0b3-"+
|
||||||
|
"1220e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", got)
|
||||||
|
}
|
||||||
+8
-5
@@ -223,11 +223,14 @@ type MFFileOuter struct {
|
|||||||
// uuid must match the uuid in the inner message
|
// uuid must match the uuid in the inner message
|
||||||
Uuid []byte `protobuf:"bytes,105,opt,name=uuid,proto3" json:"uuid,omitempty"`
|
Uuid []byte `protobuf:"bytes,105,opt,name=uuid,proto3" json:"uuid,omitempty"`
|
||||||
InnerMessage []byte `protobuf:"bytes,199,opt,name=innerMessage,proto3" json:"innerMessage,omitempty"`
|
InnerMessage []byte `protobuf:"bytes,199,opt,name=innerMessage,proto3" json:"innerMessage,omitempty"`
|
||||||
//detached signature, ascii or binary
|
// one OpenPGP signature, armored or binary, over the canonical string
|
||||||
|
// ZNAVSRFG-<uuid>-<sha256 as a multihash>, with the uuid as 32
|
||||||
|
// lower-case hex digits and no hyphens, and the multihash in lower-case
|
||||||
|
// hex (see docs/FORMAT.md). A signature is never stored anywhere else.
|
||||||
Signature []byte `protobuf:"bytes,201,opt,name=signature,proto3,oneof" json:"signature,omitempty"`
|
Signature []byte `protobuf:"bytes,201,opt,name=signature,proto3,oneof" json:"signature,omitempty"`
|
||||||
//full GPG key id
|
// fingerprint of the signing primary key, in upper-case hex
|
||||||
Signer []byte `protobuf:"bytes,202,opt,name=signer,proto3,oneof" json:"signer,omitempty"`
|
Signer []byte `protobuf:"bytes,202,opt,name=signer,proto3,oneof" json:"signer,omitempty"`
|
||||||
//full GPG signing public key, ascii or binary
|
// OpenPGP public key of the signer, one primary key, armored or binary
|
||||||
SigningPubKey []byte `protobuf:"bytes,203,opt,name=signingPubKey,proto3,oneof" json:"signingPubKey,omitempty"`
|
SigningPubKey []byte `protobuf:"bytes,203,opt,name=signingPubKey,proto3,oneof" json:"signingPubKey,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -330,8 +333,8 @@ type MFFilePath struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// required attributes:
|
// required attributes:
|
||||||
// Path invariants: must be valid UTF-8, use forward slashes only,
|
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||||
// be relative (no leading /), contain no ".." segments, and no
|
// be relative (no leading /), contain no ".." or "." segments, and
|
||||||
// empty segments (no "//").
|
// no empty segments (no "//").
|
||||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||||
Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
|
Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
|
||||||
// gotta have at least one:
|
// gotta have at least one:
|
||||||
|
|||||||
+10
-8
@@ -32,23 +32,25 @@ message MFFileOuter {
|
|||||||
bytes uuid = 105;
|
bytes uuid = 105;
|
||||||
|
|
||||||
bytes innerMessage = 199;
|
bytes innerMessage = 199;
|
||||||
// 2xx for optional manifest root attributes
|
|
||||||
// think we might use gosignify instead of gpg:
|
|
||||||
// github.com/frankbraun/gosignify
|
|
||||||
|
|
||||||
//detached signature, ascii or binary
|
// 2xx for optional manifest root attributes
|
||||||
|
|
||||||
|
// one OpenPGP signature, armored or binary, over the canonical string
|
||||||
|
// ZNAVSRFG-<uuid>-<sha256 as a multihash>, with the uuid as 32
|
||||||
|
// lower-case hex digits and no hyphens, and the multihash in lower-case
|
||||||
|
// hex (see docs/FORMAT.md). A signature is never stored anywhere else.
|
||||||
optional bytes signature = 201;
|
optional bytes signature = 201;
|
||||||
//full GPG key id
|
// fingerprint of the signing primary key, in upper-case hex
|
||||||
optional bytes signer = 202;
|
optional bytes signer = 202;
|
||||||
//full GPG signing public key, ascii or binary
|
// OpenPGP public key of the signer, one primary key, armored or binary
|
||||||
optional bytes signingPubKey = 203;
|
optional bytes signingPubKey = 203;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MFFilePath {
|
message MFFilePath {
|
||||||
// required attributes:
|
// required attributes:
|
||||||
// Path invariants: must be valid UTF-8, use forward slashes only,
|
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||||
// be relative (no leading /), contain no ".." segments, and no
|
// be relative (no leading /), contain no ".." or "." segments, and
|
||||||
// empty segments (no "//").
|
// no empty segments (no "//").
|
||||||
string path = 1;
|
string path = 1;
|
||||||
int64 size = 2;
|
int64 size = 2;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3d4dcb0b2f4640dd3ae6bb48b833e9f26ae9771e2d3e88bd646e7f1724dda654 mf.proto
|
1668b2f6b8639a9213873474e58dbacb3a4ef77cc27c42253cad8dd076e5f206 mf.proto
|
||||||
|
|||||||
+419
@@ -0,0 +1,419 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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, key and subkey 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
|
||||||
|
secretSubkeyPacketTag = 7
|
||||||
|
publicSubkeyPacketTag = 14
|
||||||
|
|
||||||
|
// In the body of a key or subkey packet the algorithm octet follows
|
||||||
|
// the version octet and the four-octet creation time, and from version
|
||||||
|
// 5 on a four-octet length as well (RFC 9580, section 5.5.2).
|
||||||
|
keyAlgorithmOffset = 5
|
||||||
|
firstKeyVersionWithLength = 5
|
||||||
|
keyAlgorithmOffsetAfterLength = 9
|
||||||
|
|
||||||
|
// signingKeyVersion is the only OpenPGP key version mfer signs with.
|
||||||
|
// Its fingerprints are 40 hex characters, the length
|
||||||
|
// --require-signature takes.
|
||||||
|
signingKeyVersion = 4
|
||||||
|
|
||||||
|
// armorBegin and armorEnd start the lines that begin and end an
|
||||||
|
// armored block.
|
||||||
|
armorBegin = "-----BEGIN "
|
||||||
|
armorEnd = "-----END "
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errKeyCount = errors.New("must hold exactly one key")
|
||||||
|
errDSAKey = errors.New("must not hold a DSA key")
|
||||||
|
errSecretKey = errors.New("must not hold a secret key")
|
||||||
|
errNoSecretKey = errors.New("signing key file holds no secret key")
|
||||||
|
errNotV4Key = errors.New("signing key must be an OpenPGP version 4 key, " +
|
||||||
|
"the only kind whose fingerprint --require-signature takes")
|
||||||
|
errNoPassphrase = errors.New(
|
||||||
|
"signing key is protected and no passphrase was given")
|
||||||
|
errNotOneSignature = errors.New(
|
||||||
|
"signature must hold exactly one signature")
|
||||||
|
errNotOneArmoredBlock = errors.New(
|
||||||
|
"must be exactly one armored block and nothing else")
|
||||||
|
errMalformedArmor = errors.New("armor is malformed")
|
||||||
|
errKeyNotValidWhenSigned = errors.New(
|
||||||
|
"signature was made when its key was not valid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 version 4 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretKeyFingerprint returns the fingerprint of the OpenPGP secret key
|
||||||
|
// secretKey, armored or binary, as a manifest signed with it records its
|
||||||
|
// signer. It fails unless secretKey holds one version 4 primary key with
|
||||||
|
// its secret key.
|
||||||
|
func SecretKeyFingerprint(secretKey []byte) (string, error) {
|
||||||
|
key, err := readSecretKey(secretKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSigningKey fails unless opts can sign now: opts.SecretKey must hold
|
||||||
|
// one version 4 primary key with a secret key that may sign and has not
|
||||||
|
// expired or been revoked, and opts.Passphrase must unlock it when it is
|
||||||
|
// protected. It lets a caller find a key that cannot sign before it builds
|
||||||
|
// a manifest.
|
||||||
|
func CheckSigningKey(opts *SigningOptions) error {
|
||||||
|
key, err := readSigningKey(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing nothing fails wherever signing the manifest would.
|
||||||
|
err = openpgp.DetachSign(io.Discard, key, bytes.NewReader(nil), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("signing key cannot sign: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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 be a version 4 key and include its secret key.
|
||||||
|
func readSecretKey(secretKey []byte) (*openpgp.Entity, error) {
|
||||||
|
key, err := readOneKey(secretKey, "signing key file", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.PrimaryKey.Version != signingKeyVersion {
|
||||||
|
return nil, fmt.Errorf("%w; this key is version %d",
|
||||||
|
errNotV4Key, key.PrimaryKey.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 and no DSA key or subkey, and when publicOnly no
|
||||||
|
// secret key or subkey either. what names data in errors.
|
||||||
|
func readOneKey(data []byte, what string, publicOnly bool) (*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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openpgp.ReadKeyRing checks the numbers of every secret key it reads,
|
||||||
|
// and an ElGamal secret subkey with a very large prime makes that take
|
||||||
|
// minutes.
|
||||||
|
secretKeys, err := countPackets(packets, secretKeyPacketTag, secretSubkeyPacketTag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", what, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if publicOnly && secretKeys != 0 {
|
||||||
|
return nil, fmt.Errorf("%s %w", what, errSecretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openpgp.ReadKeyRing also checks every self-signature, and a DSA key
|
||||||
|
// with very large numbers makes each check take seconds to minutes.
|
||||||
|
dsa, err := holdsDSAKey(packets)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", what, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dsa {
|
||||||
|
return nil, fmt.Errorf("%s %w", what, errDSAKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, unexpired OpenPGP
|
||||||
|
// signature over data, made by the one primary key in pubKey or one of its
|
||||||
|
// subkeys while that key was valid, and returns that primary key's
|
||||||
|
// fingerprint. The key may have expired since. signature and pubKey may
|
||||||
|
// each be armored or binary.
|
||||||
|
func verifySignature(data, signature, pubKey []byte) (string, error) {
|
||||||
|
key, err := readOneKey(pubKey, "embedded public key block", true)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, _, err := openpgp.VerifyDetachedSignature(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, unless the signature has expired
|
||||||
|
// itself. The library does not check that once it finds the key
|
||||||
|
// expired.
|
||||||
|
if errors.Is(err, pgperrors.ErrKeyExpired) && sig.SigExpired(time.Now()) {
|
||||||
|
err = pgperrors.ErrSignatureExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, pgperrors.ErrKeyExpired) {
|
||||||
|
return "", fmt.Errorf("verify signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check above judges the key as of now. Checking the signature a
|
||||||
|
// second time with the library's clock set to its creation time
|
||||||
|
// requires the primary key, and the subkey that made the signature, to
|
||||||
|
// have been valid then under the key validity rules of RFC 9580. The
|
||||||
|
// key is judged by its current self-signatures and binding signatures:
|
||||||
|
// for a version 4 key the newest self-signature on its primary user
|
||||||
|
// ID, for a version 6 key its newest direct-key signature, and for a
|
||||||
|
// subkey its newest binding signature with the signature embedded in
|
||||||
|
// it. So a signature made before the newest of those is refused.
|
||||||
|
signedAt := &packet.Config{
|
||||||
|
Time: func() time.Time { return sig.CreationTime },
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = openpgp.VerifyDetachedSignature(openpgp.EntityList{key},
|
||||||
|
bytes.NewReader(data), bytes.NewReader(sigData), signedAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("verify signature: %w: %w",
|
||||||
|
errKeyNotValidWhenSigned, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dearmor returns the binary OpenPGP data in data: data itself when it is
|
||||||
|
// not armored, or else the body of its armored block. Armored data must be
|
||||||
|
// one block and nothing else: its first line is the only BEGIN line and
|
||||||
|
// its last line the only END line, white space around them aside.
|
||||||
|
// armor.Decode skips any text before a BEGIN line and reads only the first
|
||||||
|
// block, so without this a second key or signature would go unseen.
|
||||||
|
func dearmor(data []byte) ([]byte, error) {
|
||||||
|
if !bytes.Contains(data, []byte(armorBegin)) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := bytes.TrimSpace(data)
|
||||||
|
lastLine := text[bytes.LastIndexByte(text, '\n')+1:]
|
||||||
|
|
||||||
|
if !bytes.HasPrefix(text, []byte(armorBegin)) ||
|
||||||
|
bytes.Count(text, []byte(armorBegin)) != 1 ||
|
||||||
|
!bytes.HasPrefix(lastLine, []byte(armorEnd)) ||
|
||||||
|
bytes.Count(text, []byte(armorEnd)) != 1 {
|
||||||
|
return nil, errNotOneArmoredBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// armor.Decode passes over a block it cannot read, such as one with a
|
||||||
|
// header line that has no colon, and returns io.EOF on finding no
|
||||||
|
// other.
|
||||||
|
block, err := armor.Decode(bytes.NewReader(text))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errMalformedArmor
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(block.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", errMalformedArmor, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, 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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsDSAKey reports whether any key or subkey packet in the binary
|
||||||
|
// OpenPGP data holds a DSA key. It reads each packet's algorithm octet
|
||||||
|
// rather than parsing the packet, since parsing a secret key packet checks
|
||||||
|
// its numbers, which for a DSA key with very large numbers is as slow as
|
||||||
|
// checking a self-signature.
|
||||||
|
func holdsDSAKey(data []byte) (bool, error) {
|
||||||
|
packets := packet.NewOpaqueReader(bytes.NewReader(data))
|
||||||
|
|
||||||
|
for {
|
||||||
|
p, err := packets.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains([]uint8{
|
||||||
|
secretKeyPacketTag, publicKeyPacketTag,
|
||||||
|
secretSubkeyPacketTag, publicSubkeyPacketTag,
|
||||||
|
}, p.Tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := keyAlgorithmOffset
|
||||||
|
if len(p.Contents) > 0 && p.Contents[0] >= firstKeyVersionWithLength {
|
||||||
|
offset = keyAlgorithmOffsetAfterLength
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.Contents) > offset &&
|
||||||
|
packet.PublicKeyAlgorithm(p.Contents[offset]) == packet.PubKeyAlgoDSA {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,972 @@
|
|||||||
|
//nolint:testpackage // white-box tests exercise unexported internals
|
||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/dsa" //nolint:staticcheck // SA1019: tests need a DSA key to refuse
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"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/elgamal"
|
||||||
|
pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
|
||||||
|
"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-----")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManifestSigner checks that Signer gives the signing key's
|
||||||
|
// fingerprint for a signed manifest, and nil once the signature is taken
|
||||||
|
// out, although the signer field is still there.
|
||||||
|
func TestManifestSigner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := newTestKey(t, nil)
|
||||||
|
data := signedTestManifest(t, &SigningOptions{SecretKey: armoredSecretKeys(t, key)})
|
||||||
|
|
||||||
|
signed, err := NewManifestFromReader(bytes.NewReader(data))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fingerprint(key), string(signed.Signer()))
|
||||||
|
|
||||||
|
unsigned, err := NewManifestFromReader(bytes.NewReader(
|
||||||
|
rewriteOuter(t, data, func(outer *MFFileOuter) { outer.Signature = nil })))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, unsigned.Signer())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerWithSigning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretKeyFingerprint checks that SecretKeyFingerprint gives the
|
||||||
|
// signer that a manifest signed with the key records.
|
||||||
|
func TestSecretKeyFingerprint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := testSigningOptions(t)
|
||||||
|
|
||||||
|
got, err := SecretKeyFingerprint(opts.SecretKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(bytes.NewReader(signedTestManifest(t, opts)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, string(m.Signer()), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigningKeyFileWithTwoKeys signs with a key file that holds two keys,
|
||||||
|
// as gpg writes it for a user ID that two keys have. It names no one key
|
||||||
|
// to sign with, so signing must fail.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckSigningKeyRefusesKeyThatCannotSign checks a key that can sign,
|
||||||
|
// and keys that read and need no passphrase but cannot sign now: one that
|
||||||
|
// expired in 2020 and one that has been revoked. CheckSigningKey must
|
||||||
|
// refuse each of the two, as signing a manifest with it would fail.
|
||||||
|
func TestCheckSigningKeyRefusesKeyThatCannotSign(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.NoError(t, CheckSigningKey(testSigningOptions(t)))
|
||||||
|
|
||||||
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
expired := newTestKey(t, &packet.Config{
|
||||||
|
Time: func() time.Time { return made },
|
||||||
|
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
|
||||||
|
})
|
||||||
|
|
||||||
|
revoked := newTestKey(t, nil)
|
||||||
|
require.NoError(t, revoked.RevokeKey(packet.KeyRetired, "", nil))
|
||||||
|
|
||||||
|
for name, key := range map[string]*openpgp.Entity{
|
||||||
|
"expired": expired,
|
||||||
|
"revoked": revoked,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.ErrorContains(t, CheckSigningKey(
|
||||||
|
&SigningOptions{SecretKey: armoredSecretKeys(t, key)}),
|
||||||
|
"signing key cannot sign")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigningRefusesVersion6Key signs with an OpenPGP version 6 key, whose
|
||||||
|
// fingerprint is 64 hex characters. mfer signs only with version 4 keys.
|
||||||
|
func TestSigningRefusesVersion6Key(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test",
|
||||||
|
&packet.Config{V6Keys: true, Algorithm: packet.PubKeyAlgoEd25519})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
secretKey := armoredSecretKeys(t, key)
|
||||||
|
|
||||||
|
_, err = SecretKeyIsProtected(secretKey)
|
||||||
|
require.ErrorIs(t, err, errNotV4Key)
|
||||||
|
|
||||||
|
b := NewBuilder()
|
||||||
|
b.SetSigningOptions(&SigningOptions{SecretKey: secretKey})
|
||||||
|
require.ErrorIs(t, b.Build(context.Background(), io.Discard), errNotV4Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMalformedArmorIsNamed reads a signing key file, a signature and an
|
||||||
|
// embedded public key block whose armor is malformed: a header line with
|
||||||
|
// no colon, or no blank line after the BEGIN line. Each must fail saying
|
||||||
|
// the armor is malformed.
|
||||||
|
func TestMalformedArmorIsNamed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := testSigningOptions(t)
|
||||||
|
manifest := signedTestManifest(t, opts)
|
||||||
|
|
||||||
|
for name, change := range map[string]func([]byte) []byte{
|
||||||
|
"header line with no colon": func(block []byte) []byte {
|
||||||
|
return bytes.Replace(block,
|
||||||
|
[]byte("-----\n"), []byte("-----\nno colon\n"), 1)
|
||||||
|
},
|
||||||
|
"no blank line after the BEGIN line": func(block []byte) []byte {
|
||||||
|
return bytes.Replace(block, []byte("-----\n\n"), []byte("-----\n"), 1)
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := SecretKeyIsProtected(change(opts.SecretKey))
|
||||||
|
require.ErrorIs(t, err, errMalformedArmor)
|
||||||
|
|
||||||
|
for _, changed := range [][]byte{
|
||||||
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
||||||
|
outer.Signature = change(outer.GetSignature())
|
||||||
|
}),
|
||||||
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
||||||
|
outer.SigningPubKey = change(outer.GetSigningPubKey())
|
||||||
|
}),
|
||||||
|
} {
|
||||||
|
_, err = NewManifestFromReader(bytes.NewReader(changed))
|
||||||
|
require.ErrorIs(t, err, errMalformedArmor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 two signatures made in 2020
|
||||||
|
// by a key that expired a day after it was made. A signature with no
|
||||||
|
// expiry time is still good, as is one made then by a signing subkey that
|
||||||
|
// expired a day after it was made. One that expired an hour after it was
|
||||||
|
// made is refused, although the key has expired too.
|
||||||
|
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, expiringSig bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, openpgp.ArmoredDetachSign(&sig, key, bytes.NewReader(data), config))
|
||||||
|
require.NoError(t, openpgp.ArmoredDetachSign(&expiringSig, key,
|
||||||
|
bytes.NewReader(data), &packet.Config{
|
||||||
|
Time: config.Time,
|
||||||
|
SigLifetimeSecs: uint32(time.Hour.Seconds()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
withSubkey := newTestKey(t, &packet.Config{Time: config.Time})
|
||||||
|
require.NoError(t, withSubkey.AddSigningSubkey(&packet.Config{
|
||||||
|
Algorithm: packet.PubKeyAlgoEdDSA, Time: config.Time,
|
||||||
|
KeyLifetimeSecs: config.KeyLifetimeSecs,
|
||||||
|
}))
|
||||||
|
|
||||||
|
var subkeySig bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, openpgp.ArmoredDetachSign(&subkeySig, withSubkey,
|
||||||
|
bytes.NewReader(data), config))
|
||||||
|
|
||||||
|
subkeyPubKey, err := armoredPublicKey(withSubkey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = verifySignature(data, subkeySig.Bytes(), subkeyPubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = verifySignature(data, expiringSig.Bytes(), pubKey)
|
||||||
|
require.ErrorIs(t, err, pgperrors.ErrSignatureExpired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signDated returns a detached signature over data by signer, dated when.
|
||||||
|
// openpgp.DetachSign signs only with a key that is valid at the time its
|
||||||
|
// config gives, so this makes the signature packet without that check.
|
||||||
|
func signDated(t *testing.T, signer *packet.PrivateKey, data []byte,
|
||||||
|
when time.Time,
|
||||||
|
) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sig := &packet.Signature{
|
||||||
|
SigType: packet.SigTypeBinary,
|
||||||
|
PubKeyAlgo: signer.PubKeyAlgo,
|
||||||
|
Hash: crypto.SHA256,
|
||||||
|
CreationTime: when,
|
||||||
|
IssuerKeyId: &signer.KeyId,
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := sig.PrepareSign(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = h.Write(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, sig.Sign(h, signer, nil))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, sig.Serialize(&buf))
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifySignatureRefusesKeyNotValidWhenSigned verifies signatures
|
||||||
|
// dated when the key or subkey that made them was not valid: after the key
|
||||||
|
// expired; after the key's self-signature, or the signing subkey's binding
|
||||||
|
// signature, expired, the key or subkey having expired since as well; and
|
||||||
|
// before the key was made, also by a key made in 2100. Each must be
|
||||||
|
// refused.
|
||||||
|
func TestVerifySignatureRefusesKeyNotValidWhenSigned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
twoDaysLater := made.Add(48 * time.Hour)
|
||||||
|
day := uint32((24 * time.Hour).Seconds())
|
||||||
|
clock := func(when time.Time) func() time.Time {
|
||||||
|
return func() time.Time { return when }
|
||||||
|
}
|
||||||
|
|
||||||
|
expiredAfterADay := newTestKey(t, &packet.Config{
|
||||||
|
Time: clock(made), KeyLifetimeSecs: day,
|
||||||
|
})
|
||||||
|
selfSignatureExpiredAfterADay := newTestKey(t, &packet.Config{
|
||||||
|
Time: clock(made), KeyLifetimeSecs: 30 * day, SigLifetimeSecs: day,
|
||||||
|
})
|
||||||
|
|
||||||
|
withSubkey := newTestKey(t, &packet.Config{Time: clock(made)})
|
||||||
|
require.NoError(t, withSubkey.AddSigningSubkey(&packet.Config{
|
||||||
|
Algorithm: packet.PubKeyAlgoEdDSA, Time: clock(made),
|
||||||
|
KeyLifetimeSecs: 30 * day, SigLifetimeSecs: day,
|
||||||
|
}))
|
||||||
|
subkey := withSubkey.Subkeys[len(withSubkey.Subkeys)-1].PrivateKey
|
||||||
|
|
||||||
|
madeADayLater := newTestKey(t, &packet.Config{
|
||||||
|
Time: clock(made.Add(24 * time.Hour)),
|
||||||
|
})
|
||||||
|
madeIn2100 := newTestKey(t, &packet.Config{
|
||||||
|
Time: clock(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||||
|
})
|
||||||
|
|
||||||
|
for name, test := range map[string]struct {
|
||||||
|
key *openpgp.Entity
|
||||||
|
signer *packet.PrivateKey
|
||||||
|
when time.Time
|
||||||
|
}{
|
||||||
|
"after the key expired": {
|
||||||
|
expiredAfterADay, expiredAfterADay.PrivateKey, twoDaysLater,
|
||||||
|
},
|
||||||
|
"after the self-signature expired": {
|
||||||
|
selfSignatureExpiredAfterADay,
|
||||||
|
selfSignatureExpiredAfterADay.PrivateKey, twoDaysLater,
|
||||||
|
},
|
||||||
|
"after the binding signature expired": {
|
||||||
|
withSubkey, subkey, twoDaysLater,
|
||||||
|
},
|
||||||
|
"before the key was made": {
|
||||||
|
madeADayLater, madeADayLater.PrivateKey, made,
|
||||||
|
},
|
||||||
|
"before a key made in 2100": {
|
||||||
|
madeIn2100, madeIn2100.PrivateKey, made,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := []byte("signed in 2020")
|
||||||
|
|
||||||
|
pubKey, err := armoredPublicKey(test.key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = verifySignature(data,
|
||||||
|
signDated(t, test.signer, data, test.when), pubKey)
|
||||||
|
require.ErrorIs(t, err, errKeyNotValidWhenSigned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestSignatureVerification(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 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: as a
|
||||||
|
// public key, 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{
|
||||||
|
"public key": armoredPublicKeys(t, other, signer),
|
||||||
|
"secret key without user ID": armoredSecretKeys(t, signer, 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{}
|
||||||
|
|
||||||
|
signer := newTestKey(t, nil)
|
||||||
|
|
||||||
|
manifest := rewriteOuter(t, signedTestManifest(t,
|
||||||
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)}),
|
||||||
|
func(outer *MFFileOuter) {
|
||||||
|
outer.SigningPubKey = armoredPublicKeys(t, other, signer)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
||||||
|
require.ErrorIs(t, err, errKeyCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsaKeyPacket returns a public key packet holding a DSA key, or a public
|
||||||
|
// subkey packet when isSubkey. Its numbers are not a working key: loading
|
||||||
|
// must refuse the packet before it uses them.
|
||||||
|
func dsaKeyPacket(t *testing.T, isSubkey bool) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key := packet.NewDSAPublicKey(time.Now(), &dsa.PublicKey{
|
||||||
|
P: big.NewInt(23), Q: big.NewInt(11), G: big.NewInt(4), Y: big.NewInt(8),
|
||||||
|
})
|
||||||
|
key.IsSubkey = isSubkey
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, key.Serialize(&buf))
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManifestRefusesDSAKey loads manifests whose embedded public key
|
||||||
|
// block holds a DSA key: alone, or as a subkey after the key that signed
|
||||||
|
// the manifest. openpgp.ReadKeyRing checks a key's self-signatures, which
|
||||||
|
// for a DSA key with very large numbers takes minutes each. Loading must
|
||||||
|
// refuse each block before that.
|
||||||
|
func TestManifestRefusesDSAKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := newTestKey(t, nil)
|
||||||
|
manifest := signedTestManifest(t,
|
||||||
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)})
|
||||||
|
|
||||||
|
var signerKey bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, signer.Serialize(&signerKey))
|
||||||
|
|
||||||
|
for name, block := range map[string][]byte{
|
||||||
|
"DSA key": dsaKeyPacket(t, false),
|
||||||
|
"DSA subkey": slices.Concat(signerKey.Bytes(), dsaKeyPacket(t, true)),
|
||||||
|
} {
|
||||||
|
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, errDSAKey)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// elGamalSecretSubkeyPacket returns a secret subkey packet holding an
|
||||||
|
// ElGamal key. Its numbers are not a working key: loading must refuse the
|
||||||
|
// packet before it uses them.
|
||||||
|
func elGamalSecretSubkeyPacket(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key := packet.NewElGamalPrivateKey(time.Now(), &elgamal.PrivateKey{
|
||||||
|
P: big.NewInt(23), G: big.NewInt(4), Y: big.NewInt(8), X: big.NewInt(3),
|
||||||
|
})
|
||||||
|
key.IsSubkey = true
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, key.Serialize(&buf))
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManifestRefusesSecretKey loads manifests whose embedded public key
|
||||||
|
// block holds a secret key: the key that signed the manifest with its
|
||||||
|
// secret key, or its public key followed by an ElGamal secret subkey.
|
||||||
|
// openpgp.ReadKeyRing checks the numbers of every secret key it reads,
|
||||||
|
// which for an ElGamal key with a very large prime takes minutes. Loading
|
||||||
|
// must refuse each block before that.
|
||||||
|
func TestManifestRefusesSecretKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := newTestKey(t, nil)
|
||||||
|
manifest := signedTestManifest(t,
|
||||||
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)})
|
||||||
|
|
||||||
|
var signerKey bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, signer.Serialize(&signerKey))
|
||||||
|
|
||||||
|
for _, block := range [][]byte{
|
||||||
|
armoredSecretKeys(t, signer),
|
||||||
|
slices.Concat(signerKey.Bytes(), elGamalSecretSubkeyPacket(t)),
|
||||||
|
} {
|
||||||
|
embedded := rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
||||||
|
outer.SigningPubKey = block
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader(embedded))
|
||||||
|
require.ErrorIs(t, err, errSecretKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManifestRefusesTwoSignatures loads a manifest whose signature field
|
||||||
|
// holds its good signature twice, not armored. Loading must refuse it.
|
||||||
|
func TestManifestRefusesTwoSignatures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifest := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
|
||||||
|
func(outer *MFFileOuter) {
|
||||||
|
sig, err := dearmor(outer.GetSignature())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
outer.Signature = slices.Concat(sig, sig)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
||||||
|
require.ErrorIs(t, err, errNotOneSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManifestRefusesFieldNotOneArmoredBlock loads manifests whose
|
||||||
|
// signature or embedded public key block holds its good armored block with
|
||||||
|
// something else: a second armored block, text after the END line, or many
|
||||||
|
// END lines before the block. Decoding the block once for each END line
|
||||||
|
// before it would take time and memory that grow with the square of the
|
||||||
|
// field's size. Loading must refuse each.
|
||||||
|
func TestManifestRefusesFieldNotOneArmoredBlock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifest := signedTestManifest(t, testSigningOptions(t))
|
||||||
|
|
||||||
|
for name, change := range map[string]func([]byte) []byte{
|
||||||
|
"second armored block": func(block []byte) []byte {
|
||||||
|
return joinArmored(block, block)
|
||||||
|
},
|
||||||
|
"text after the END line": func(block []byte) []byte {
|
||||||
|
return slices.Concat(block, []byte("\nmore text\n"))
|
||||||
|
},
|
||||||
|
"END lines before the block": func(block []byte) []byte {
|
||||||
|
return slices.Concat(
|
||||||
|
[]byte(strings.Repeat(armorEnd+"\n", 1000)), block)
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, changed := range [][]byte{
|
||||||
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
||||||
|
outer.Signature = change(outer.GetSignature())
|
||||||
|
}),
|
||||||
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
||||||
|
outer.SigningPubKey = change(outer.GetSigningPubKey())
|
||||||
|
}),
|
||||||
|
} {
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader(changed))
|
||||||
|
require.ErrorIs(t, err, errNotOneArmoredBlock)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box tests exercise unexported internals
|
||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pipeDeadline is how long a test lets a run go before it opens the test's
|
||||||
|
// named pipes for writing.
|
||||||
|
const pipeDeadline = 5 * time.Second
|
||||||
|
|
||||||
|
// makePipe makes a named pipe at path. Opening a named pipe for reading
|
||||||
|
// blocks until it is opened for writing, so once pipeDeadline has passed,
|
||||||
|
// unless the test has ended, makePipe opens it for writing and closes it
|
||||||
|
// again. A run that wrongly reads the pipe then ends, and the test fails on
|
||||||
|
// what it reports instead of hanging. That lets one read through, so each
|
||||||
|
// path a wrong run would read gets a pipe of its own.
|
||||||
|
func makePipe(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, syscall.Mkfifo(path, 0o600))
|
||||||
|
|
||||||
|
timer := time.AfterFunc(pipeDeadline, func() {
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY, 0) //nolint:gosec // test-controlled path
|
||||||
|
if err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() { timer.Stop() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerSkipsPipes scans a tree holding a named pipe and a symlink to
|
||||||
|
// another one, following symlinks: the manifest lists the regular file only.
|
||||||
|
func TestScannerSkipsPipes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, testFile1), []byte("a"), 0o600))
|
||||||
|
makePipe(t, filepath.Join(dir, "pipe"))
|
||||||
|
|
||||||
|
// The symlink points outside the tree, so no pipe is read twice.
|
||||||
|
target := filepath.Join(t.TempDir(), "pipe")
|
||||||
|
makePipe(t, target)
|
||||||
|
require.NoError(t, os.Symlink(target, filepath.Join(dir, "link")))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{FollowSymLinks: true})
|
||||||
|
require.NoError(t, s.EnumeratePath(dir, nil))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, m.Files(), 1)
|
||||||
|
assert.Equal(t, testFile1, m.Files()[0].GetPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckPipes checks a tree against a manifest that lists, as an empty
|
||||||
|
// file, a path that is a named pipe on disk. The tree holds another pipe the
|
||||||
|
// manifest does not list. check reports the listed pipe as an error without
|
||||||
|
// reading it, and the search for extra files does not report the other.
|
||||||
|
func TestCheckPipes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(dir, "index.mf")
|
||||||
|
content := []byte("in manifest")
|
||||||
|
listedPipe := RelFilePath("listed-pipe")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
createTestManifest(t, fs, manifestPath, map[string][]byte{
|
||||||
|
testFile1: content,
|
||||||
|
string(listedPipe): {},
|
||||||
|
})
|
||||||
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(dir, testFile1), content, 0o600))
|
||||||
|
makePipe(t, filepath.Join(dir, string(listedPipe)))
|
||||||
|
makePipe(t, filepath.Join(dir, "unlisted-pipe"))
|
||||||
|
|
||||||
|
chk, err := NewChecker(&CheckerOptions{
|
||||||
|
ManifestPath: manifestPath,
|
||||||
|
BasePath: dir,
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
require.NoError(t, chk.Check(context.Background(), results, nil))
|
||||||
|
|
||||||
|
byPath := map[RelFilePath]Result{}
|
||||||
|
for r := range results {
|
||||||
|
byPath[r.Path] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, StatusOK, byPath[testFile1].Status)
|
||||||
|
assert.Equal(t, Result{
|
||||||
|
Path: listedPipe,
|
||||||
|
Status: StatusError,
|
||||||
|
Message: "not a regular file",
|
||||||
|
}, byPath[listedPipe])
|
||||||
|
|
||||||
|
extras := make(chan Result, 10)
|
||||||
|
require.NoError(t, chk.FindExtraFiles(context.Background(), extras))
|
||||||
|
|
||||||
|
var extraPaths []RelFilePath
|
||||||
|
for r := range extras {
|
||||||
|
extraPaths = append(extraPaths, r.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, extraPaths)
|
||||||
|
}
|
||||||
+50
-18
@@ -2,6 +2,7 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
@@ -57,7 +58,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 GPG signing options (nil = no signing).
|
// SigningOptions holds the key to sign with (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
|
||||||
@@ -79,7 +80,8 @@ type FileEntry struct {
|
|||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
files []*FileEntry
|
files []*FileEntry
|
||||||
totalBytes FileSize // cached sum of all file sizes
|
paths map[RelFilePath]AbsFilePath // the file at each path in files
|
||||||
|
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
|
||||||
@@ -103,6 +105,7 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -136,28 +139,20 @@ func (s *Scanner) EnumerateFile(filePath string) error {
|
|||||||
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumeratePath walks a directory path and adds all files to the scanner.
|
// EnumeratePath adds inputPath, a directory or a file, to the scanner as
|
||||||
|
// EnumeratePaths adds each of its paths.
|
||||||
// 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) EnumeratePath(
|
func (s *Scanner) EnumeratePath(
|
||||||
inputPath string,
|
inputPath string,
|
||||||
progress chan<- EnumerateStatus,
|
progress chan<- EnumerateStatus,
|
||||||
) error {
|
) error {
|
||||||
if progress != nil {
|
return s.EnumeratePaths(progress, inputPath)
|
||||||
defer close(progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
abs, err := filepath.Abs(inputPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
|
||||||
|
|
||||||
return s.enumerateFS(afs, abs, progress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumeratePaths walks multiple directory paths and adds all files to the scanner.
|
// EnumeratePaths adds to the scanner the files under each directory path,
|
||||||
|
// 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(
|
||||||
@@ -174,9 +169,27 @@ func (s *Scanner) EnumeratePaths(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
info, err := s.fs.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// The walk does not follow a symlink at its top, so a directory
|
||||||
|
// named through one is resolved first. If that fails, the
|
||||||
|
// directory is walked as named and the walk reports the problem.
|
||||||
|
resolved, evalErr := filepath.EvalSymlinks(abs)
|
||||||
|
if evalErr == nil {
|
||||||
|
abs = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -461,6 +474,13 @@ func (s *Scanner) enumerateFileWithInfo(
|
|||||||
info = realInfo
|
info = realInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opening a named pipe can block, and reading a device may never end.
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
log.Verbosef("skipping %s: not a regular file", cleanPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, excluded := range s.excluded {
|
for _, excluded := range s.excluded {
|
||||||
if os.SameFile(info, excluded) {
|
if os.SameFile(info, excluded) {
|
||||||
return nil
|
return nil
|
||||||
@@ -478,6 +498,18 @@ 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))
|
||||||
|
|||||||
@@ -118,6 +118,27 @@ func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
|||||||
assert.Equal(t, FileSize(6), final.BytesFound)
|
assert.Equal(t, FileSize(6), final.BytesFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScannerEnumeratePathFile gives EnumeratePath a file: it is listed
|
||||||
|
// by its name, as EnumerateFile lists it, and the manifest can be built.
|
||||||
|
func TestScannerEnumeratePathFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/dir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/dir/one.txt", []byte("1"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
require.NoError(t, s.EnumeratePath("/dir/one.txt", nil))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, m.Files(), 1)
|
||||||
|
assert.Equal(t, "one.txt", m.Files()[0].GetPath())
|
||||||
|
}
|
||||||
|
|
||||||
func TestScannerEnumeratePaths(t *testing.T) {
|
func TestScannerEnumeratePaths(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -134,6 +155,28 @@ 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()
|
||||||
|
|
||||||
|
|||||||
+31
-23
@@ -7,9 +7,11 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -20,11 +22,9 @@ 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("internal error: pbInner not set")
|
errInnerNotSet = errors.New("inner message not set")
|
||||||
// errInternal is returned by generateOuter for the same condition.
|
// errInternal is returned by generateOuter for the same condition.
|
||||||
// The two messages differ, and both are load-bearing for callers that
|
errInternal = errors.New("inner message not set")
|
||||||
// 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("serialize: marshal outer: %w", err)
|
return fmt.Errorf("marshal outer message: %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("serialize: write output: %w", err)
|
return fmt.Errorf("write outer message: %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("serialize: marshal inner: %w", err)
|
return fmt.Errorf("marshal inner message: %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("serialize: create compressor: %w", err)
|
return fmt.Errorf("create compressor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = zw.Write(innerData)
|
_, err = zw.Write(innerData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("serialize: compress: %w", err)
|
return fmt.Errorf("compress inner message: %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("serialize: hash write: %w", err)
|
return fmt.Errorf("hash inner message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sha256Hash := h.Sum(nil)
|
sha256Hash := h.Sum(nil)
|
||||||
@@ -135,40 +135,48 @@ 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 && m.signingOptions.KeyID != "" {
|
if m.signingOptions != nil {
|
||||||
return m.signOuter(ctx)
|
return m.signOuter(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// signOuter signs the outer message with the configured GPG key and
|
// signOuter signs the outer message with the secret key in the signing
|
||||||
// embeds the signature, signer fingerprint, and public key.
|
// options and embeds the signature, the key's fingerprint and its public
|
||||||
|
// 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("failed to generate signature string: %w", err)
|
return fmt.Errorf("build signature string: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sig, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
|
key, err := readSigningKey(m.signingOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to sign manifest: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pbOuter.Signature = sig
|
var sig bytes.Buffer
|
||||||
|
|
||||||
fingerprint, err := gpgGetKeyFingerprint(ctx, m.signingOptions.KeyID)
|
err = openpgp.ArmoredDetachSign(&sig, key, strings.NewReader(sigString), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
return fmt.Errorf("sign manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pbOuter.Signer = fingerprint
|
pubKey, err := armoredPublicKey(key)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(ctx, m.signingOptions.KeyID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to export public key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.pbOuter.Signature = sig.Bytes()
|
||||||
|
m.pbOuter.Signer = []byte(fingerprint(key))
|
||||||
m.pbOuter.SigningPubKey = pubKey
|
m.pbOuter.SigningPubKey = pubKey
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user