Compare commits
1
Commits
next
..
953fca87f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
953fca87f9 |
@@ -13,15 +13,9 @@ The structure of these files can be found
|
|||||||
schema `mfer/mf.proto` for exact field numbers and types. Both are included in
|
schema `mfer/mf.proto` for exact field numbers and types. Both are included in
|
||||||
the [project repository](https://git.eeqj.de/sneak/mfer).
|
the [project repository](https://git.eeqj.de/sneak/mfer).
|
||||||
|
|
||||||
Releases are git tags named by [SemVer](https://semver.org), starting with
|
The current version is pre-1.0 and while the repo was published in 2022, there
|
||||||
`v1.0.0`. No file in the tree records a version number. `mfer version` prints
|
has not yet been any versioned release. [SemVer](https://semver.org) will be
|
||||||
the revision the build stamped into the binary, which for `make build` and the
|
used for releases.
|
||||||
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
|
||||||
@@ -295,12 +289,9 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
|||||||
else the directory holding the manifest, or the current directory for a
|
else the directory holding the manifest, or the current directory for a
|
||||||
manifest given by URL. So `mfer check /media/drive` checks a drive against
|
manifest given by URL. So `mfer check /media/drive` checks a drive against
|
||||||
the `index.mf` at its root, from any directory
|
the `index.mf` at its root, from any directory
|
||||||
- warns about each regular file under the base directory that the manifest
|
- warns about each file under the base directory that the manifest does not
|
||||||
does not list, hidden files included, and each symlink there to a regular
|
list, hidden files included; with `--no-extra-files` each one is a failure
|
||||||
file, which gen lists only with `--follow-symlinks`; with
|
instead
|
||||||
`--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 .`
|
- `mfer freshen` / `mfer freshen .`
|
||||||
- rewrites `index.mf` to list the files now under the directory holding it,
|
- 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
|
or under the one `--base` names, hashing only the files that are new or
|
||||||
@@ -309,9 +300,6 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
|||||||
- leaves out hidden files unless given `--include-dotfiles`, and symlinks
|
- leaves out hidden files unless given `--include-dotfiles`, and symlinks
|
||||||
unless given `--follow-symlinks`, which lists each symlink to a regular
|
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
|
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`
|
- `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
|
- 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
|
binary, as `gpg --export-secret-keys` writes it; `MFER_SIGN_KEY` names the
|
||||||
|
|||||||
+6
-4
@@ -10,12 +10,14 @@ import (
|
|||||||
// Appname is the name of this program.
|
// Appname is the name of this program.
|
||||||
const Appname = "mfer"
|
const Appname = "mfer"
|
||||||
|
|
||||||
// Gitrev is the revision `mfer version` prints, injected at build time via
|
// Version and Gitrev are injected at build time via -ldflags.
|
||||||
// -ldflags.
|
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // set via ldflags at build time
|
//nolint:gochecknoglobals // set via ldflags at build time
|
||||||
var Gitrev string
|
var (
|
||||||
|
Version string
|
||||||
|
Gitrev string
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(cli.Run(Appname, Gitrev))
|
os.Exit(cli.Run(Appname, Version, Gitrev))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ func TestAppname(t *testing.T) {
|
|||||||
assert.Equal(t, "mfer", Appname)
|
assert.Equal(t, "mfer", Appname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGitrevDefault documents that Gitrev is empty unless injected at build
|
// TestVersionDefaults documents that Version and Gitrev are empty unless
|
||||||
// time via -ldflags.
|
// injected at build time via -ldflags.
|
||||||
func TestGitrevDefault(t *testing.T) {
|
func TestVersionDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Empty(t, Version)
|
||||||
assert.Empty(t, Gitrev)
|
assert.Empty(t, Gitrev)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-229
@@ -20,14 +20,9 @@ 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, so a `.mf` is
|
message. The protobuf message extends to the end of the file.
|
||||||
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`)
|
||||||
|
|
||||||
@@ -39,7 +34,7 @@ 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 | 16-byte UUID; must match the inner message UUID |
|
| `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID |
|
||||||
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
|
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
|
||||||
| `signature` | 201 | bytes (optional) | One OpenPGP signature over the canonical string, armored or binary |
|
| `signature` | 201 | bytes (optional) | One OpenPGP signature over the canonical string, armored or binary |
|
||||||
| `signer` | 202 | bytes (optional) | Fingerprint of the signing primary key, in upper-case hex |
|
| `signer` | 202 | bytes (optional) | Fingerprint of the signing primary key, in upper-case hex |
|
||||||
@@ -69,39 +64,40 @@ 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 | 16-byte UUID; must match outer UUID |
|
| `uuid` | 102 | bytes | Random v4 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` | Must include exactly one SHA-256 multihash (see Hash Format) |
|
| `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
|
||||||
|
|
||||||
`mode` holds a file's Unix permission bits, the nine `rwx` bits, so it is never
|
`mode` holds a file's Unix permission bits, the nine `rwx` bits, so it is never
|
||||||
above `0777` (octal); the setuid, setgid and sticky bits are never recorded. A
|
above `0777` (octal); the setuid, setgid and sticky bits are never recorded.
|
||||||
reader must reject a manifest with an entry whose mode is above `0777`. Writers
|
Writers record `0000` unless whoever creates the manifest asks for permissions.
|
||||||
record `0000` unless whoever creates the manifest asks for permissions. `0000`,
|
`0000`, the proto3 default, means no mode was recorded: readers never check or
|
||||||
the proto3 default, means no mode was recorded: readers never check or apply it.
|
apply it.
|
||||||
|
|
||||||
The reference implementation records modes when `gen` or `freshen` is given
|
The reference implementation records modes when `gen` or `freshen` is given
|
||||||
`--include-permissions`. `check` fails a file whose permission bits differ from
|
`--include-permissions`. `check` fails a file whose permission bits differ from
|
||||||
a recorded mode other than `0000`. `fetch` sets a recorded mode other than
|
a recorded mode other than `0000`. `fetch` sets a recorded mode other than
|
||||||
`0000` on each file it writes.
|
`0000` on each file it writes, and refuses a manifest that records a mode above
|
||||||
|
`0777` before it requests any file.
|
||||||
|
|
||||||
## Path Rules
|
## Path Rules
|
||||||
|
|
||||||
@@ -111,7 +107,6 @@ 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
|
- **Listed once**: each path appears at most once in a manifest, compared byte
|
||||||
@@ -121,10 +116,6 @@ Implementations must validate these invariants when reading and writing
|
|||||||
manifests. Paths that violate these rules must be rejected, and a reader must
|
manifests. Paths that violate these rules must be rejected, and a reader must
|
||||||
reject a manifest that lists a path more than once.
|
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`)
|
||||||
|
|
||||||
Each checksum is a single `bytes multiHash` field containing a
|
Each checksum is a single `bytes multiHash` field containing a
|
||||||
@@ -132,16 +123,8 @@ Each checksum is a single `bytes multiHash` field containing a
|
|||||||
self-describing: the encoded bytes include a varint algorithm identifier
|
self-describing: the encoded bytes include a varint algorithm identifier
|
||||||
followed by a varint digest length followed by the digest itself.
|
followed by a varint digest length followed by the digest itself.
|
||||||
|
|
||||||
A writer must give every file entry exactly one SHA-256 multihash of the file's
|
The 1.0 implementation writes SHA-256 multihashes (`0x12` algorithm code).
|
||||||
content: the algorithm code `0x12`, the digest length `0x20` and the 32 bytes of
|
Implementations must be able to verify SHA-256 multihashes at minimum.
|
||||||
the digest. It may list multihashes of other algorithms beside it. A reader must
|
|
||||||
refuse a manifest with an entry that has no SHA-256 multihash or more than one,
|
|
||||||
whatever other hashes the entry lists. A verifier then needs only SHA-256,
|
|
||||||
checking each file against its entry's SHA-256 multihash; it may ignore the
|
|
||||||
other hashes.
|
|
||||||
|
|
||||||
The reference implementation writes one hash for each entry, the SHA-256
|
|
||||||
multihash, and its `check` and `fetch` compute only SHA-256.
|
|
||||||
|
|
||||||
## Signature Scheme
|
## Signature Scheme
|
||||||
|
|
||||||
@@ -191,198 +174,21 @@ 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
|
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.
|
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
|
A signing key that has expired does not make the signature bad, because a
|
||||||
signature, were valid at the signature's creation time under the key validity
|
manifest outlives its key. A signature past its own expiry time is not good,
|
||||||
rules of RFC 9580. The key is judged by its current self-signatures and binding
|
whether or not the key has expired too.
|
||||||
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
|
||||||
|
|
||||||
The reference implementation writes the same inner message for the same files,
|
By default, manifests are generated deterministically:
|
||||||
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 byte order, so `B.txt` comes before
|
- File entries are sorted by `path` in **lexicographic byte order**
|
||||||
`a.txt`.
|
- `createdAt` is omitted unless explicitly requested
|
||||||
- Each message is encoded by the Go protobuf library with deterministic
|
- `mode` is `0000` unless explicitly requested
|
||||||
marshaling, which writes fields in field-number order and leaves out the
|
|
||||||
fields that are not set. A field not declared `optional` counts as not set
|
|
||||||
when it holds its default value, zero or empty.
|
|
||||||
- Each entry holds only `path`, `size`, `hashes` with exactly one hash, `mtime`
|
|
||||||
and `mode`. The hash is the SHA-256 multihash of the file's content, except
|
|
||||||
that `freshen` reads only the files that are new or whose size, `mtime` or
|
|
||||||
`mode` changed, and for every other file carries the SHA-256 multihash of its
|
|
||||||
existing entry over unchanged, leaving out any other hash the entry lists.
|
|
||||||
`mtime` is the file's modification time as the file system gives it, to the
|
|
||||||
nanosecond, and `mode` is `0000` unless `gen` or `freshen` is given
|
|
||||||
`--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.
|
|
||||||
|
|
||||||
So two runs of `gen` over an unchanged tree write byte-identical files only when
|
This ensures that two independent runs over the same directory tree produce
|
||||||
both are given the same `--seed`, neither is given `--include-timestamps`, and
|
byte-identical `.mf` files (assuming file contents and metadata have not
|
||||||
neither signs the manifest: a signature records the time it was made, and the
|
changed).
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
+54
-36
@@ -2,7 +2,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -95,36 +94,47 @@ func findManifest(fs afero.Fs, dir string) (string, error) {
|
|||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadManifest downloads the manifest at url into memory. It refuses a
|
// fetchManifestToTemp downloads a manifest URL to a temporary file and
|
||||||
// manifest larger than mfa.maxManifestSize.
|
// returns the temp file path. The caller is responsible for removing it.
|
||||||
func (mfa *CLIApp) downloadManifest(ctx context.Context, url string) ([]byte, error) {
|
func (mfa *CLIApp) fetchManifestToTemp(
|
||||||
rc, err := mfa.openManifestReader(ctx, url)
|
ctx context.Context, url string,
|
||||||
if err != nil {
|
) (string, error) {
|
||||||
return nil, err
|
rc, fetchErr := mfa.openManifestReader(ctx, url)
|
||||||
|
if fetchErr != nil {
|
||||||
|
return "", fetchErr
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rc.Close() }()
|
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
||||||
|
if tmpErr != nil {
|
||||||
|
_ = rc.Close()
|
||||||
|
|
||||||
// Reading stops one byte past mfa.maxManifestSize, which is enough to
|
return "", tmpErr
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
|
// Copying stops one byte past mfa.maxManifestSize, which is enough to
|
||||||
// tell that the manifest is too large.
|
// tell that the manifest is too large.
|
||||||
manifestData, err := io.ReadAll(io.LimitReader(rc, mfa.maxManifestSize+1))
|
written, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfa.maxManifestSize+1))
|
||||||
if err != nil {
|
_ = rc.Close()
|
||||||
return nil, fmt.Errorf("download manifest: %w", err)
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
|
if cpErr == nil && written > mfa.maxManifestSize {
|
||||||
|
cpErr = fmt.Errorf("%w of %d bytes", errManifestTooLarge, mfa.maxManifestSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
if int64(len(manifestData)) > mfa.maxManifestSize {
|
if cpErr != nil {
|
||||||
return nil, fmt.Errorf("download manifest: %w of %d bytes",
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
errManifestTooLarge, mfa.maxManifestSize)
|
|
||||||
|
return "", fmt.Errorf("download manifest: %w", cpErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return manifestData, nil
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||||
// against signer, the fingerprint of the key that made the manifest's
|
// against the key that made the manifest's signature.
|
||||||
// signature, which is empty when the manifest is not signed. Loading the
|
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||||
// manifest checked that signer is that key's fingerprint.
|
|
||||||
func verifyRequiredSigner(signer, 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))
|
||||||
@@ -135,11 +145,15 @@ func verifyRequiredSigner(signer, requiredSigner string) error {
|
|||||||
return fmt.Errorf("invalid fingerprint: %w", err)
|
return fmt.Errorf("invalid fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if signer == "" {
|
if !chk.IsSigned() {
|
||||||
return fmt.Errorf("%w, but signature from %s is required",
|
return fmt.Errorf("%w, but signature from %s is required",
|
||||||
errManifestNotSigned, requiredSigner)
|
errManifestNotSigned, requiredSigner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading the manifest checked that the signer is the fingerprint of
|
||||||
|
// the key that made the signature.
|
||||||
|
signer := string(chk.Signer())
|
||||||
|
|
||||||
// Compare fingerprints - must be exact match (case-insensitive)
|
// Compare fingerprints - must be exact match (case-insensitive)
|
||||||
if !strings.EqualFold(signer, 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",
|
||||||
@@ -285,36 +299,40 @@ func (mfa *CLIApp) checkManifestOperation(
|
|||||||
return 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)
|
basePath := resolveBasePath(cmd, manifestPath)
|
||||||
|
|
||||||
opts := &mfer.CheckerOptions{BasePath: basePath, Fs: mfa.Fs}
|
// URL manifests need to be downloaded to a temp file for the checker
|
||||||
|
|
||||||
// A manifest given by URL is downloaded into memory and read from
|
|
||||||
// there.
|
|
||||||
if isHTTPURL(manifestPath) {
|
if isHTTPURL(manifestPath) {
|
||||||
manifestData, downloadErr := mfa.downloadManifest(ctx, manifestPath)
|
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
|
||||||
if downloadErr != nil {
|
if tmpErr != nil {
|
||||||
return downloadErr
|
return tmpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.ManifestReader = bytes.NewReader(manifestData)
|
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
||||||
} else {
|
|
||||||
opts.ManifestPath = manifestPath
|
manifestPath = tmpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
chk, err := mfer.NewChecker(opts)
|
// Create checker
|
||||||
|
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||||
|
ManifestPath: manifestPath,
|
||||||
|
BasePath: basePath,
|
||||||
|
Fs: mfa.Fs,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load manifest: %w", err)
|
return fmt.Errorf("load manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check signature requirement. An empty fingerprint is checked too,
|
// Check signature requirement
|
||||||
// and refused as one of the wrong length.
|
requiredSigner := cmd.String(flagRequireSignature)
|
||||||
if cmd.IsSet(flagRequireSignature) {
|
if requiredSigner != "" {
|
||||||
err = verifyRequiredSigner(string(chk.Signer()), cmd.String(flagRequireSignature))
|
err = verifyRequiredSigner(chk, requiredSigner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ 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
|
||||||
Gitrev string // Revision stamped in at build time, or empty
|
Version string // Version string (typically set at build time)
|
||||||
|
Gitrev string // Git revision hash (typically set at build time)
|
||||||
Args []string // Command-line arguments (typically os.Args)
|
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
|
||||||
@@ -35,9 +36,10 @@ type RunOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
|
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
|
||||||
func DefaultRunOptions(appname, gitrev string) *RunOptions {
|
func DefaultRunOptions(appname, version, 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,
|
||||||
@@ -48,14 +50,15 @@ func DefaultRunOptions(appname, 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, gitrev string) int {
|
func Run(appname, version, gitrev string) int {
|
||||||
return RunWithOptions(DefaultRunOptions(appname, gitrev))
|
return RunWithOptions(DefaultRunOptions(appname, version, 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,
|
||||||
|
|||||||
+28
-213
@@ -11,7 +11,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/debug"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -28,7 +27,6 @@ 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"
|
||||||
@@ -38,8 +36,6 @@ 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")
|
||||||
@@ -62,10 +58,9 @@ 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: test helpers also
|
// lock it points the process-global logger at io.Discard: other tests log
|
||||||
// log under runMu outside any run (manifest loads, scans), and those lines
|
// outside the lock (manifest loads, scans), and those lines must not land in
|
||||||
// must not land in this run's buffers once it has returned and its test is
|
// this run's buffers once it has returned and its test is reading them.
|
||||||
// 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()
|
||||||
@@ -98,7 +93,8 @@ 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,
|
||||||
Gitrev: testGitrev,
|
Version: "1.0.0",
|
||||||
|
Gitrev: "abc123",
|
||||||
Args: args,
|
Args: args,
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
Stdout: &bytes.Buffer{},
|
Stdout: &bytes.Buffer{},
|
||||||
@@ -140,35 +136,10 @@ 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionFromBuildInfo covers a binary whose build stamped no Gitrev,
|
stdout := testStdout(t, opts)
|
||||||
// as `go install` builds it: mfer version prints the main module's version
|
assert.Contains(t, stdout, mfer.Version)
|
||||||
// from the build information instead.
|
assert.Contains(t, stdout, "abc123")
|
||||||
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
|
||||||
@@ -194,7 +165,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), testGitrev)
|
assert.Contains(t, testStdout(t, opts), mfer.Version)
|
||||||
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
|
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -235,13 +206,13 @@ func TestVersionFlagAndCommandMatch(t *testing.T) {
|
|||||||
|
|
||||||
// TestRootVerbosityFlags asserts that -q and -v given before any subcommand
|
// 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).
|
// subcommands (issue #64). It does not run in parallel: other tests log
|
||||||
|
// outside runMu (manifest loads, scans), and a line logged while one of these
|
||||||
|
// runs is in progress lands in its output.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // see above
|
||||||
func TestRootVerbosityFlags(t *testing.T) {
|
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)
|
||||||
@@ -253,8 +224,6 @@ 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))
|
||||||
@@ -263,8 +232,6 @@ 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))
|
||||||
@@ -272,8 +239,6 @@ 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))
|
||||||
@@ -356,7 +321,7 @@ func TestHelpCommand(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
opts := testOpts([]string{testApp, testFlagHelp}, fs)
|
opts := testOpts([]string{testApp, "--help"}, fs)
|
||||||
|
|
||||||
exitCode := runCLI(opts)
|
exitCode := runCLI(opts)
|
||||||
|
|
||||||
@@ -774,9 +739,6 @@ 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) {
|
||||||
@@ -809,9 +771,13 @@ 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)
|
||||||
paths := manifestPaths(t, fs, testMF)
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
require.Len(t, paths, 1)
|
Path: testMF,
|
||||||
assert.Equal(t, "file1.txt", paths[0])
|
Fs: fs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 1)
|
||||||
|
assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||||
@@ -832,7 +798,12 @@ 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)
|
||||||
assert.Len(t, manifestPaths(t, fs, testMF), 2)
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
|
Path: testMF,
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipleInputPaths(t *testing.T) {
|
func TestMultipleInputPaths(t *testing.T) {
|
||||||
@@ -989,53 +960,6 @@ func TestCheckWarnsAboutUnlistedFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckReportsUnlistedSymlinkToFileOnly makes a tree holding a symlink
|
|
||||||
// to a directory, one to a file and a broken one. gen lists the symlink to
|
|
||||||
// the file only with --follow-symlinks, and never lists the other two, so
|
|
||||||
// check --no-extra-files passes on the manifest gen --follow-symlinks writes
|
|
||||||
// and fails only on the symlink to the file on the one plain gen writes.
|
|
||||||
func TestCheckReportsUnlistedSymlinkToFileOnly(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
|
||||||
genFlags []string
|
|
||||||
exitCode int
|
|
||||||
extras []string // what check reports as not in the manifest
|
|
||||||
}{
|
|
||||||
"gen --follow-symlinks": {[]string{"--follow-symlinks"}, 0, nil},
|
|
||||||
"plain gen": {nil, 1, []string{"filelink"}},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
writeTestTree(t, fs, dir)
|
|
||||||
require.NoError(t, os.Symlink(testSubdir, filepath.Join(dir, "dirlink")))
|
|
||||||
require.NoError(t, os.Symlink(testFileTxt, filepath.Join(dir, "filelink")))
|
|
||||||
require.NoError(t,
|
|
||||||
os.Symlink("nowhere", filepath.Join(dir, "brokenlink")))
|
|
||||||
|
|
||||||
opts := testOpts(slices.Concat(
|
|
||||||
[]string{testApp, cmdGenerate, "-q"}, tc.genFlags, []string{dir},
|
|
||||||
), fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
|
|
||||||
opts = testOpts([]string{testApp, cmdCheck, testFlagNoExtra, dir}, fs)
|
|
||||||
exitCode := runCLI(opts)
|
|
||||||
stderr := testStderr(t, opts)
|
|
||||||
|
|
||||||
assert.Equal(t, tc.exitCode, exitCode, "stderr: %s", stderr)
|
|
||||||
assert.Equal(t, len(tc.extras),
|
|
||||||
strings.Count(stderr, "not in manifest"), stderr)
|
|
||||||
|
|
||||||
for _, extra := range tc.extras {
|
|
||||||
assert.Contains(t, stderr, "EXTRA: "+extra)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckNeverReportsManifest keeps the manifest inside the checked tree,
|
// TestCheckNeverReportsManifest keeps the manifest inside the checked tree,
|
||||||
// under several names and path spellings, and names the tree both directly
|
// under several names and path spellings, and names the tree both directly
|
||||||
// and through a symlink: the manifest is never reported, even under
|
// and through a symlink: the manifest is never reported, even under
|
||||||
@@ -1167,29 +1091,6 @@ func TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory(t *testing.T) {
|
|||||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckURLManifestWithoutTempDirectory checks the tree under --base
|
|
||||||
// against a manifest given by URL while TMPDIR names a directory that does
|
|
||||||
// not exist. check keeps the manifest it downloads in memory, so it passes.
|
|
||||||
// Setting TMPDIR keeps it from running in parallel with other tests.
|
|
||||||
func TestCheckURLManifestWithoutTempDirectory(t *testing.T) {
|
|
||||||
files := map[string][]byte{testFileTxt: []byte("hello")}
|
|
||||||
|
|
||||||
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
require.NoError(t,
|
|
||||||
os.WriteFile(filepath.Join(base, testFileTxt), files[testFileTxt], 0o600))
|
|
||||||
|
|
||||||
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
|
|
||||||
|
|
||||||
opts := testOpts([]string{
|
|
||||||
testApp, cmdCheck, testFlagNoExtra, testFlagBase, base,
|
|
||||||
server.URL + "/" + defaultManifestName,
|
|
||||||
}, afero.NewOsFs())
|
|
||||||
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
// unlistableDirFs is a filesystem on which one directory cannot be listed.
|
// unlistableDirFs is a filesystem on which one directory cannot be listed.
|
||||||
type unlistableDirFs struct {
|
type unlistableDirFs struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
@@ -1323,11 +1224,6 @@ 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,
|
||||||
@@ -1911,87 +1807,6 @@ func TestGenerateReadsPathAfterDoubleDash(t *testing.T) {
|
|||||||
assert.Equal(t, testFileTxt, files[0].GetPath())
|
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) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+46
-45
@@ -50,6 +50,33 @@ func runLocked(fn func() error) error {
|
|||||||
return fn()
|
return fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unsignedChecker builds a Checker over a freshly scanned, unsigned manifest.
|
||||||
|
func unsignedChecker(t *testing.T) *mfer.Checker {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
||||||
|
|
||||||
|
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: fs})
|
||||||
|
require.NoError(t, s.EnumeratePath("/d", nil))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/d/index.mf", buf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||||
|
ManifestPath: "/d/index.mf",
|
||||||
|
BasePath: "/d",
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, chk.IsSigned())
|
||||||
|
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
func TestNoManifestFoundMessage(t *testing.T) {
|
func TestNoManifestFoundMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -65,7 +92,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("", "12345678")
|
err := verifyRequiredSigner(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")
|
||||||
@@ -74,7 +101,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("", msgFpA)
|
err := verifyRequiredSigner(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")
|
||||||
@@ -89,13 +116,13 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|||||||
func TestSignerMismatchMessage(t *testing.T) {
|
func TestSignerMismatchMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
signer := string(signedChecker(t,
|
chk := signedChecker(t,
|
||||||
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")})).Signer())
|
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
|
||||||
|
|
||||||
err := verifyRequiredSigner(signer, msgFpB)
|
err := verifyRequiredSigner(chk, msgFpB)
|
||||||
require.ErrorIs(t, err, errSignerMismatch)
|
require.ErrorIs(t, err, errSignerMismatch)
|
||||||
assert.EqualError(t, err,
|
assert.EqualError(t, err,
|
||||||
"embedded signing key fingerprint "+signer+
|
"embedded signing key fingerprint "+string(chk.Signer())+
|
||||||
" does not match required "+msgFpB)
|
" does not match required "+msgFpB)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +183,6 @@ func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
|||||||
func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
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))
|
||||||
|
|
||||||
@@ -451,47 +473,26 @@ func TestFreshenReadErrorMessage(t *testing.T) {
|
|||||||
"read "+filepath.Join(root, "sub")+": is a directory")
|
"read "+filepath.Join(root, "sub")+": is a directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFreshenSizeChangedMessage has freshen hash a file that has grown or
|
// TestFreshenSizeChangedMessage has freshen hash a file that has grown
|
||||||
// shrunk since the scan recorded its size.
|
// since the scan recorded its size.
|
||||||
func TestFreshenSizeChangedMessage(t *testing.T) {
|
func TestFreshenSizeChangedMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
writeTestFile(t, fs, "/d/log.txt", "hello")
|
writeTestFile(t, fs, "/d/log.txt", "hello")
|
||||||
|
|
||||||
t.Run("grown", func(t *testing.T) {
|
hasher := &freshenHasher{
|
||||||
t.Parallel()
|
fs: fs,
|
||||||
|
absBase: "/d",
|
||||||
|
builder: mfer.NewBuilder(),
|
||||||
|
}
|
||||||
|
|
||||||
hasher := &freshenHasher{
|
err := hasher.processEntry(
|
||||||
fs: fs,
|
&freshenEntry{path: "log.txt", size: 3, needsHash: true})
|
||||||
absBase: "/d",
|
require.ErrorIs(t, err, errSizeMismatch)
|
||||||
builder: mfer.NewBuilder(),
|
assert.EqualError(t, err, `size mismatch for "log.txt": `+
|
||||||
}
|
`3 bytes when scanned but 5 bytes when read; `+
|
||||||
|
`the file changed while freshen ran`)
|
||||||
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) {
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ 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
-45
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/multiformats/go-multihash"
|
"github.com/multiformats/go-multihash"
|
||||||
|
"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"
|
"sneak.berlin/go/mfer/mfer"
|
||||||
@@ -95,6 +96,9 @@ var (
|
|||||||
// writes another file.
|
// writes another file.
|
||||||
errNameClash = errors.New(
|
errNameClash = errors.New(
|
||||||
"manifest lists a file where fetch writes another file")
|
"manifest lists a file where fetch writes another file")
|
||||||
|
// errModeOutOfRange indicates a manifest that lists a mode with more
|
||||||
|
// than the permission bits, such as setuid.
|
||||||
|
errModeOutOfRange = errors.New("manifest lists a mode outside 0777")
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress reports the progress of a single file download.
|
// DownloadProgress reports the progress of a single file download.
|
||||||
@@ -122,9 +126,8 @@ 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. If ctx ends before a retry, the error wraps
|
// returned as errHTTPStatus. use must start over each time it is called,
|
||||||
// both ctx's error and the last try's error. use must start over each
|
// since a retry calls it again with a new response.
|
||||||
// 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 {
|
||||||
@@ -166,19 +169,15 @@ func (c retryingClient) get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !retry || attempt == fetchAttempts {
|
if !retry || attempt == fetchAttempts || ctx.Err() != nil {
|
||||||
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 fmt.Errorf("%w: %w", ctx.Err(), err)
|
return ctx.Err()
|
||||||
case <-time.After(wait):
|
case <-time.After(wait):
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,20 +424,12 @@ func (mfa *CLIApp) fetchManifestOperation(
|
|||||||
|
|
||||||
// fetchManifest downloads the manifest at manifestURL and parses it,
|
// fetchManifest downloads the manifest at manifestURL and parses it,
|
||||||
// enforcing --require-signature if it is given and refusing a manifest
|
// enforcing --require-signature if it is given and refusing a manifest
|
||||||
// that lists a file where fetch writes another. It returns the manifest as
|
// that lists a file where fetch writes another or a mode outside 0777. It
|
||||||
// downloaded, to be saved once the files are in place, and the files it
|
// returns the manifest as downloaded, to be saved once the files are in
|
||||||
// lists.
|
// place, and the files it lists.
|
||||||
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
|
||||||
@@ -470,8 +461,9 @@ func (mfa *CLIApp) fetchManifest(
|
|||||||
return nil, nil, fmt.Errorf("parse manifest: %w", err)
|
return nil, nil, fmt.Errorf("parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requiredSigner := cmd.String(flagRequireSignature)
|
||||||
if requiredSigner != "" {
|
if requiredSigner != "" {
|
||||||
err = verifyRequiredSigner(string(manifest.Signer()), requiredSigner)
|
err = verifyFetchedSigner(manifestData, requiredSigner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -484,6 +476,16 @@ func (mfa *CLIApp) fetchManifest(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetch sets each recorded mode on the file it writes, so a mode above
|
||||||
|
// 0777, which could carry setuid, setgid or sticky bits, is refused
|
||||||
|
// before any file is requested.
|
||||||
|
for _, f := range files {
|
||||||
|
if f.GetMode() > uint32(os.ModePerm) {
|
||||||
|
return nil, nil, fmt.Errorf("%w: %s (%#o)",
|
||||||
|
errModeOutOfRange, f.GetPath(), f.GetMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("manifest contains %d files", len(files))
|
log.Infof("manifest contains %d files", len(files))
|
||||||
|
|
||||||
return manifestData, files, nil
|
return manifestData, files, nil
|
||||||
@@ -539,6 +541,31 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyFetchedSigner enforces --require-signature on the fetched manifest
|
||||||
|
// exactly as check does. verifyRequiredSigner takes a Checker, which loads
|
||||||
|
// its manifest from a file, so the manifest is handed to it as a file in
|
||||||
|
// memory.
|
||||||
|
func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
|
||||||
|
memFs := afero.NewMemMapFs()
|
||||||
|
manifestPath := "/" + defaultManifestName
|
||||||
|
|
||||||
|
err := afero.WriteFile(memFs, manifestPath, manifestData, filePerms)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||||
|
ManifestPath: manifestPath,
|
||||||
|
BasePath: "/",
|
||||||
|
Fs: memFs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyRequiredSigner(chk, requiredSigner)
|
||||||
|
}
|
||||||
|
|
||||||
// saveManifest writes the fetched manifest into dest under the default
|
// saveManifest writes the fetched manifest into dest under the default
|
||||||
// manifest name, the way fetch writes every file: to a new temp file that
|
// manifest name, the way fetch writes every file: to a new temp file that
|
||||||
// is then renamed into place.
|
// is then renamed into place.
|
||||||
@@ -653,26 +680,21 @@ 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
|
||||||
lastProgress time.Time
|
progress chan<- DownloadProgress
|
||||||
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
|
||||||
@@ -694,8 +716,6 @@ 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
|
||||||
@@ -880,16 +900,13 @@ func saveResponse(
|
|||||||
// Set up hash computation
|
// Set up hash computation
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
|
||||||
// Create progress-reporting writer that also computes hash. The first
|
// Create progress-reporting writer that also computes hash
|
||||||
// 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: start,
|
startTime: time.Now(),
|
||||||
lastProgress: start,
|
progress: progress,
|
||||||
progress: progress,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content while hashing and reporting progress. One byte past
|
// Copy content while hashing and reporting progress. One byte past
|
||||||
|
|||||||
+110
-108
@@ -19,12 +19,15 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"uuid"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
"github.com/multiformats/go-multihash"
|
"github.com/multiformats/go-multihash"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,11 +171,6 @@ 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))
|
||||||
|
|
||||||
@@ -316,11 +314,8 @@ 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. Loading a manifest logs to
|
// Parse the manifest to get file entries
|
||||||
// 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()
|
||||||
@@ -370,10 +365,7 @@ 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()
|
||||||
@@ -419,10 +411,7 @@ 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()
|
||||||
@@ -486,17 +475,18 @@ func TestFetchStopsReadingFilePastListedSize(t *testing.T) {
|
|||||||
|
|
||||||
// TestManifestDownloadStopsAtLimit serves a manifest that never ends to
|
// TestManifestDownloadStopsAtLimit serves a manifest that never ends to
|
||||||
// fetch and to check, with the most they download of a manifest lowered
|
// fetch and to check, with the most they download of a manifest lowered
|
||||||
// to 64 KiB. Each must stop reading at that limit and fail with an error
|
// to 64 KiB. Each must stop reading at that limit, fail with an error
|
||||||
// naming it.
|
// naming it and leave no temp file.
|
||||||
func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(
|
server := httptest.NewServer(
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.Copy(w, zeros{})
|
_, _ = io.Copy(w, zeros{})
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("TMPDIR", tmpDir)
|
||||||
|
|
||||||
mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10}
|
mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10}
|
||||||
|
|
||||||
fetch := mfa.fetchCommand()
|
fetch := mfa.fetchCommand()
|
||||||
@@ -516,23 +506,24 @@ func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
|||||||
"download manifest: file exceeds maximum allowed size of 65536 bytes",
|
"download manifest: file exceeds maximum allowed size of 65536 bytes",
|
||||||
cmd.Name)
|
cmd.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
leftover, err := os.ReadDir(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, leftover)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // changes the process-global working directory
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
func TestFetchProgress(t *testing.T) {
|
func TestFetchProgress(t *testing.T) {
|
||||||
// Create source filesystem with a larger test file
|
// Create source filesystem with a larger test file
|
||||||
sourceFs := afero.NewMemMapFs()
|
sourceFs := afero.NewMemMapFs()
|
||||||
// Content large enough that io.Copy writes it in at least four chunks
|
// Create content large enough to trigger multiple progress updates
|
||||||
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
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()
|
||||||
@@ -568,11 +559,8 @@ 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
|
||||||
|
|
||||||
@@ -581,12 +569,6 @@ 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]
|
||||||
@@ -889,59 +871,23 @@ func TestGetHonorsRetryAfter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// instant503Transport answers every request with a 503 at once, so the
|
// TestGetStopsWaitingWhenCanceled gives get a server that always answers
|
||||||
// 503 is in hand before any deadline can pass.
|
// 503 and an hour's wait before each retry, then lets the context expire
|
||||||
type instant503Transport struct{}
|
// during that wait. get returning at all shows it stopped waiting.
|
||||||
|
|
||||||
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, "http://example.invalid/", 50*time.Millisecond)
|
err := getNothing(client, server.URL, 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
|
||||||
@@ -951,12 +897,9 @@ func TestGetStopsWhenContextEndsDuringRequest(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})
|
|
||||||
|
|
||||||
// Loading a manifest logs to the process-global logger.
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(
|
||||||
runMu.Lock()
|
manifestOf(t, map[string][]byte{testFileTxt: content})))
|
||||||
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
|
||||||
@@ -979,11 +922,8 @@ func TestDownloadFileRetriesToSuccess(t *testing.T) {
|
|||||||
|
|
||||||
chdirTemp(t)
|
chdirTemp(t)
|
||||||
|
|
||||||
// downloadFile logs each retry to the process-global logger.
|
err = downloadFile(context.Background(), testClient(),
|
||||||
err = runLocked(func() error {
|
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
|
||||||
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())
|
||||||
|
|
||||||
@@ -1303,10 +1243,10 @@ func TestFetchRequireSignature(t *testing.T) {
|
|||||||
|
|
||||||
// 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 and in capitals. Saving the
|
// temp file: as a file, as a directory, in capitals, and with a leading
|
||||||
// manifest would replace or remove what is listed there, so fetch must
|
// "./". Saving the manifest would replace or remove what is listed there,
|
||||||
// refuse the manifest before it creates the destination or requests any
|
// so fetch must refuse the manifest before it creates the destination or
|
||||||
// file.
|
// requests any file.
|
||||||
func TestFetchRefusesListedManifestName(t *testing.T) {
|
func TestFetchRefusesListedManifestName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1315,6 +1255,7 @@ 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()
|
||||||
@@ -1390,9 +1331,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 are
|
// requests any file. A file and a directory with the same name, and a
|
||||||
// refused the same way. A manifest whose names differ in more than
|
// file listed twice, are refused the same way. A manifest whose names
|
||||||
// letter case is fetched in full.
|
// differ in more than letter case is fetched in full.
|
||||||
func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1402,6 +1343,7 @@ 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()
|
||||||
@@ -1462,18 +1404,14 @@ 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)
|
||||||
|
|
||||||
@@ -1532,9 +1470,27 @@ func TestFetchSetsRecordedMode(t *testing.T) {
|
|||||||
[]string{"/" + defaultManifestName, "/" + testFileTxt}, requested)
|
[]string{"/" + defaultManifestName, "/" + testFileTxt}, requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFetchRefusesModeOutsidePermissionBits fetches manifests that record
|
||||||
|
// a mode with the setuid bit, once as Unix writes it (04755) and once as
|
||||||
|
// Go keeps it in os.FileMode. fetch must refuse both before it creates the
|
||||||
|
// destination or requests any file.
|
||||||
|
func TestFetchRefusesModeOutsidePermissionBits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files := map[string][]byte{testFileTxt: []byte("a file")}
|
||||||
|
|
||||||
|
assertFetchRefused(t, manifestWithMode(t, testFileTxt, files[testFileTxt], 0o4755),
|
||||||
|
files, "manifest lists a mode outside 0777: file.txt (04755)")
|
||||||
|
|
||||||
|
assertFetchRefused(t,
|
||||||
|
manifestWithMode(t, testFileTxt, files[testFileTxt],
|
||||||
|
uint32(os.ModeSetuid|0o755)),
|
||||||
|
files, "manifest lists a mode outside 0777: file.txt (040000755)")
|
||||||
|
}
|
||||||
|
|
||||||
// TestDownloadFileSetsOnlyPermissionBits downloads a file whose entry
|
// TestDownloadFileSetsOnlyPermissionBits downloads a file whose entry
|
||||||
// records mode 0755 together with setuid, setgid or sticky, as Go keeps
|
// records mode 0755 together with setuid, setgid or sticky, as Go keeps
|
||||||
// them in os.FileMode. Loading a manifest refuses such an entry; called
|
// them in os.FileMode. fetchManifest would refuse such an entry; called
|
||||||
// directly, downloadFile must still set only the permission bits.
|
// directly, downloadFile must still set only the permission bits.
|
||||||
func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
|
func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -1571,8 +1527,54 @@ func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// manifestWithMode returns a manifest listing one file, path, with content
|
||||||
|
// and the given recorded mode. It is assembled by hand, since the builder
|
||||||
|
// never records a mode outside 0777.
|
||||||
|
func manifestWithMode(t *testing.T, path string, content []byte, mode uint32) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
digest := sha256.Sum256(content)
|
||||||
|
hash, err := multihash.Encode(digest[:], multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
id := uuid.NewV4()
|
||||||
|
|
||||||
|
inner, err := proto.Marshal(&mfer.MFFile{
|
||||||
|
Version: mfer.MFFile_VERSION_ONE,
|
||||||
|
Files: []*mfer.MFFilePath{{
|
||||||
|
Path: path,
|
||||||
|
Size: int64(len(content)),
|
||||||
|
Hashes: []*mfer.MFFileChecksum{{MultiHash: hash}},
|
||||||
|
Mode: mode,
|
||||||
|
}},
|
||||||
|
Uuid: id[:],
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
encoder, err := zstd.NewWriter(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
compressed := encoder.EncodeAll(inner, nil)
|
||||||
|
require.NoError(t, encoder.Close())
|
||||||
|
|
||||||
|
sum := sha256.Sum256(compressed)
|
||||||
|
|
||||||
|
outer, err := proto.Marshal(&mfer.MFFileOuter{
|
||||||
|
Version: mfer.MFFileOuter_VERSION_ONE,
|
||||||
|
CompressionType: mfer.MFFileOuter_COMPRESSION_ZSTD,
|
||||||
|
Size: int64(len(inner)),
|
||||||
|
Sha256: sum[:],
|
||||||
|
Uuid: id[:],
|
||||||
|
InnerMessage: compressed,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return append([]byte(mfer.MAGIC), outer...)
|
||||||
|
}
|
||||||
|
|
||||||
// builtManifest returns a manifest of files, built directly rather than
|
// builtManifest returns a manifest of files, built directly rather than
|
||||||
// scanned, since a scan lists no hidden files.
|
// scanned, since a scan lists no hidden files and never a path starting
|
||||||
|
// with "./".
|
||||||
func builtManifest(t *testing.T, files map[string][]byte) []byte {
|
func builtManifest(t *testing.T, files map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+30
-69
@@ -33,15 +33,11 @@ var (
|
|||||||
// modification time where one is required to carry it forward unchanged.
|
// modification time where one is required to carry it forward unchanged.
|
||||||
errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
||||||
|
|
||||||
// errEntryMissingHash indicates a manifest entry that carries no
|
// errEntryMissingHash indicates a manifest entry that carries no hash
|
||||||
// SHA-256 hash to carry forward.
|
// to carry forward.
|
||||||
errEntryMissingHash = errors.New("manifest entry has no SHA-256 hash")
|
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 {
|
||||||
Phase string // "scan" or "hash"
|
Phase string // "scan" or "hash"
|
||||||
@@ -365,39 +361,24 @@ func writeFreshenedManifest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newFreshenBuilder constructs the manifest builder configured from CLI
|
// newFreshenBuilder constructs the manifest builder configured from CLI
|
||||||
// flags, and returns the fingerprint of the --sign-key key, or "" when
|
// flags.
|
||||||
// none is given. A --sign-key given with an empty value is refused.
|
func (mfa *CLIApp) newFreshenBuilder(cmd *cli.Command) (*mfer.Builder, error) {
|
||||||
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 cmd.IsSet("sign-key") {
|
if signKey := cmd.String("sign-key"); signKey != "" {
|
||||||
signKey := cmd.String("sign-key")
|
|
||||||
if signKey == "" {
|
|
||||||
return nil, "", errEmptySignKey
|
|
||||||
}
|
|
||||||
|
|
||||||
signing, err := mfa.signingOptions(signKey)
|
signing, err := mfa.signingOptions(signKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
signKeyFingerprint, err = mfer.SecretKeyFingerprint(signing.SecretKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("%s: %w", signKey, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.SetSigningOptions(signing)
|
builder.SetSigningOptions(signing)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder, signKeyFingerprint, nil
|
return builder, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// freshenScan runs the scan phase against the loaded manifest entries
|
// freshenScan runs the scan phase against the loaded manifest entries
|
||||||
@@ -509,19 +490,11 @@ func runFreshenHash(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// existingManifest is what freshen uses of the manifest it rewrites.
|
// loadExistingEntries loads the manifest and indexes its file entries
|
||||||
type existingManifest struct {
|
// by path.
|
||||||
entriesByPath map[string]*mfer.MFFilePath
|
func (mfa *CLIApp) loadExistingEntries(
|
||||||
signer string // fingerprint of the key it is signed with, "" if unsigned
|
manifestPath string,
|
||||||
hasCreatedAt bool
|
) (map[string]*mfer.MFFilePath, error) {
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -533,10 +506,6 @@ func (mfa *CLIApp) loadExistingManifest(
|
|||||||
return nil, fmt.Errorf("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()
|
||||||
log.Infof("manifest contains %d files", len(existingFiles))
|
log.Infof("manifest contains %d files", len(existingFiles))
|
||||||
|
|
||||||
@@ -546,11 +515,7 @@ func (mfa *CLIApp) loadExistingManifest(
|
|||||||
existingByPath[f.GetPath()] = f
|
existingByPath[f.GetPath()] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
return &existingManifest{
|
return existingByPath, nil
|
||||||
entriesByPath: existingByPath,
|
|
||||||
signer: string(manifest.Signer()),
|
|
||||||
hasCreatedAt: manifest.CreatedAt() != nil,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mfa *CLIApp) freshenManifestOperation(
|
func (mfa *CLIApp) freshenManifestOperation(
|
||||||
@@ -558,18 +523,20 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
) error {
|
) error {
|
||||||
log.Debug("freshenManifestOperation()")
|
log.Debug("freshenManifestOperation()")
|
||||||
|
|
||||||
|
showProgress := cmd.Bool("progress")
|
||||||
|
|
||||||
// Find manifest file
|
// Find manifest file
|
||||||
manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
|
manifestPath, err := mfa.resolveFreshenManifestPath(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
builder, signKeyFingerprint, err := mfa.newFreshenBuilder(cmd)
|
builder, err := mfa.newFreshenBuilder(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := mfa.loadExistingManifest(cmd, manifestPath)
|
existingByPath, err := mfa.loadExistingEntries(manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -581,7 +548,7 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
|
|
||||||
// Phase 1: Scan filesystem
|
// Phase 1: Scan filesystem
|
||||||
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
scanner, removed, err := mfa.freshenScan(cmd, manifestPath, absBase,
|
||||||
existing.entriesByPath)
|
existingByPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -598,7 +565,7 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
hasher := &freshenHasher{
|
hasher := &freshenHasher{
|
||||||
fs: mfa.Fs,
|
fs: mfa.Fs,
|
||||||
absBase: absBase,
|
absBase: absBase,
|
||||||
showProgress: cmd.Bool("progress"),
|
showProgress: showProgress,
|
||||||
totalHashBytes: totalHashBytes,
|
totalHashBytes: totalHashBytes,
|
||||||
filesToHash: filesToHash,
|
filesToHash: filesToHash,
|
||||||
startHash: time.Now(),
|
startHash: time.Now(),
|
||||||
@@ -614,14 +581,8 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
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 only when the manifest would come out the same: nothing
|
// Skip writing if nothing changed
|
||||||
// in the tree changed, and the manifest already holds what --sign-key,
|
if scanner.changed == 0 && scanner.added == 0 && removed == 0 {
|
||||||
// --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
|
||||||
@@ -699,26 +660,26 @@ 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.
|
||||||
// Only the entry's SHA-256 hash is carried over, wherever the entry lists
|
// Loading refuses an entry with no hash; one that got here anyway is an
|
||||||
// it, since check and fetch compare with no other. Loading refuses an
|
// error too, not a file to leave out of the manifest.
|
||||||
// entry with no SHA-256 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 {
|
||||||
return fmt.Errorf("%w: %s", errEntryMissingMtime, entry.GetPath())
|
return fmt.Errorf("%w: %s", errEntryMissingMtime, entry.GetPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := mfer.SHA256Multihash(entry)
|
if len(entry.GetHashes()) == 0 {
|
||||||
if hash == nil {
|
|
||||||
return fmt.Errorf("%w: %s", errEntryMissingHash, entry.GetPath())
|
return fmt.Errorf("%w: %s", errEntryMissingHash, entry.GetPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
err := b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
||||||
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
|
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
|
||||||
fs.FileMode(entry.GetMode()), hash)
|
fs.FileMode(entry.GetMode()), entry.GetHashes()[0].GetMultiHash())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("manifest entry %s: %w", entry.GetPath(), err)
|
return fmt.Errorf(
|
||||||
|
"manifest entry %s: %w (regenerate the manifest with mfer generate)",
|
||||||
|
entry.GetPath(), err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+29
-121
@@ -2,10 +2,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -73,11 +70,6 @@ func runFreshen(t *testing.T, fs afero.Fs, root, manifestPath string) {
|
|||||||
func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath {
|
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,
|
||||||
@@ -87,23 +79,6 @@ 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) {
|
||||||
@@ -161,41 +136,6 @@ 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.
|
||||||
@@ -548,11 +488,7 @@ 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)
|
||||||
@@ -587,76 +523,48 @@ 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
|
// TestFreshenAddExistingRejectsMissingHash pins that an entry with no hash
|
||||||
// SHA-256 hash, whether it lists no hash or only a SHA-1 one, is an error
|
// is an error rather than a file left out of the rebuilt manifest.
|
||||||
// rather than a file left out of the rebuilt manifest.
|
|
||||||
func TestFreshenAddExistingRejectsMissingHash(t *testing.T) {
|
func TestFreshenAddExistingRejectsMissingHash(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
b := mfer.NewBuilder()
|
||||||
|
entry := &mfer.MFFilePath{
|
||||||
|
Path: "data.bin",
|
||||||
|
Size: 8,
|
||||||
|
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := addExistingToBuilder(b, entry)
|
||||||
|
require.ErrorIs(t, err, errEntryMissingHash)
|
||||||
|
assert.Contains(t, err.Error(), "data.bin")
|
||||||
|
assert.Zero(t, b.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenAddExistingRejectsShortHash pins that an existing manifest
|
||||||
|
// entry whose hash the builder refuses is reported as a problem with the
|
||||||
|
// manifest, with the command that fixes it.
|
||||||
|
func TestFreshenAddExistingRejectsShortHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
hashes []*mfer.MFFileChecksum
|
|
||||||
}{
|
|
||||||
{"no hash", nil},
|
|
||||||
{"SHA-1 hash only", []*mfer.MFFileChecksum{{MultiHash: sha1Hash}}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := mfer.NewBuilder()
|
|
||||||
entry := &mfer.MFFilePath{
|
|
||||||
Path: "data.bin",
|
|
||||||
Size: 8,
|
|
||||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
|
||||||
Hashes: tt.hashes,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := addExistingToBuilder(b, entry)
|
|
||||||
require.ErrorIs(t, err, errEntryMissingHash)
|
|
||||||
assert.Contains(t, err.Error(), "data.bin")
|
|
||||||
assert.Zero(t, b.FileCount())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenAddExistingKeepsSHA256Hash pins that an unchanged entry that
|
|
||||||
// lists another hash before its SHA-256 hash is carried over with the
|
|
||||||
// SHA-256 hash, the one check and fetch compare with, and nothing else.
|
|
||||||
func TestFreshenAddExistingKeepsSHA256Hash(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sha512Hash, err := multihash.Encode(make([]byte, sha512.Size), multihash.SHA2_512)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sha256Hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
b := mfer.NewBuilder()
|
b := mfer.NewBuilder()
|
||||||
entry := &mfer.MFFilePath{
|
entry := &mfer.MFFilePath{
|
||||||
Path: "kept.txt",
|
Path: "old.txt",
|
||||||
Size: 6,
|
Size: 8,
|
||||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000},
|
||||||
Hashes: []*mfer.MFFileChecksum{
|
Hashes: []*mfer.MFFileChecksum{
|
||||||
{MultiHash: sha512Hash},
|
{MultiHash: sha1Hash},
|
||||||
{MultiHash: sha256Hash},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, addExistingToBuilder(b, entry))
|
err = addExistingToBuilder(b, entry)
|
||||||
|
require.Error(t, err)
|
||||||
var buf bytes.Buffer
|
assert.Contains(t, err.Error(), "manifest entry old.txt")
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
assert.Contains(t, err.Error(), "mfer generate")
|
||||||
|
assert.Zero(t, b.FileCount())
|
||||||
m, err := mfer.NewManifestFromReader(&buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, m.Files(), 1)
|
|
||||||
require.Len(t, m.Files()[0].GetHashes(), 1)
|
|
||||||
assert.Equal(t, sha256Hash, m.Files()[0].GetHashes()[0].GetMultiHash())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEntryMtime pins the presence semantics the callers depend on.
|
// TestEntryMtime pins the presence semantics the callers depend on.
|
||||||
|
|||||||
+2
-10
@@ -29,8 +29,6 @@ var (
|
|||||||
"already exists (use --force to overwrite)")
|
"already exists (use --force to overwrite)")
|
||||||
// errEmptyOutput indicates --output given with an empty value.
|
// errEmptyOutput indicates --output given with an empty value.
|
||||||
errEmptyOutput = errors.New("--output must not be empty")
|
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
|
||||||
@@ -123,8 +121,7 @@ func (mfa *CLIApp) outputPath(cmd *cli.Command) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildScannerOptions constructs scanner options from the CLI flags and
|
// buildScannerOptions constructs scanner options from the CLI flags and
|
||||||
// the path the manifest is written to. A --sign-key given with an empty
|
// the path the manifest is written to.
|
||||||
// value is refused.
|
|
||||||
func (mfa *CLIApp) buildScannerOptions(
|
func (mfa *CLIApp) buildScannerOptions(
|
||||||
cmd *cli.Command, output string,
|
cmd *cli.Command, output string,
|
||||||
) (*mfer.ScannerOptions, error) {
|
) (*mfer.ScannerOptions, error) {
|
||||||
@@ -147,12 +144,7 @@ func (mfa *CLIApp) buildScannerOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up signing options if sign-key is provided
|
// Set up signing options if sign-key is provided
|
||||||
if cmd.IsSet("sign-key") {
|
if signKey := cmd.String("sign-key"); signKey != "" {
|
||||||
signKey := cmd.String("sign-key")
|
|
||||||
if signKey == "" {
|
|
||||||
return nil, errEmptySignKey
|
|
||||||
}
|
|
||||||
|
|
||||||
signing, err := mfa.signingOptions(signKey)
|
signing, err := mfa.signingOptions(signKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+33
-67
@@ -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.
|
||||||
@@ -58,6 +58,7 @@ var errUnexpectedArgument = errors.New("unexpected argument")
|
|||||||
//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
|
||||||
@@ -86,26 +87,13 @@ const banner = `
|
|||||||
\ \:\ \ \:\ \ \::/ \ \:\
|
\ \:\ \ \:\ \ \::/ \ \:\
|
||||||
\__\/ \__\/ \__\/ \__\/`
|
\__\/ \__\/ \__\/ \__\/`
|
||||||
|
|
||||||
// VersionString returns the version mfer reports: the revision the build
|
// VersionString returns the version and git revision formatted for display.
|
||||||
// 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 mfa.gitrev
|
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
||||||
}
|
}
|
||||||
|
|
||||||
return moduleVersion(debug.ReadBuildInfo())
|
return mfer.Version
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -118,7 +106,8 @@ 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: %s\n", mfa.VersionString())
|
" mfer by @sneak: v%s released %s\n",
|
||||||
|
mfer.Version, mfer.ReleaseDate)
|
||||||
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,17 +168,6 @@ func refuseExtraArgs(_ context.Context, cmd *cli.Command) error {
|
|||||||
return nil
|
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
|
||||||
// check and fetch subcommands.
|
// check and fetch subcommands.
|
||||||
func requireSignatureFlag() *cli.StringFlag {
|
func requireSignatureFlag() *cli.StringFlag {
|
||||||
@@ -213,12 +191,10 @@ 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 ...]",
|
||||||
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()
|
||||||
@@ -277,12 +253,10 @@ 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,
|
||||||
ArgValidator: refuseExtraArgs,
|
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()
|
||||||
@@ -313,12 +287,10 @@ 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,
|
||||||
ArgValidator: refuseExtraArgs,
|
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()
|
||||||
@@ -366,12 +338,10 @@ 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]",
|
||||||
ArgValidator: refuseExtraArgs,
|
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)
|
||||||
|
|
||||||
@@ -394,14 +364,12 @@ 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,
|
||||||
ArgValidator: refuseExtraArgs,
|
ArgValidator: refuseExtraArgs,
|
||||||
HideHelpCommand: true,
|
Action: mfa.listManifestOperation,
|
||||||
CommandNotFound: showCommandHelp,
|
|
||||||
Action: mfa.listManifestOperation,
|
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "long",
|
Name: "long",
|
||||||
@@ -418,12 +386,10 @@ 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",
|
||||||
ArgValidator: refuseExtraArgs,
|
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()
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -77,103 +73,6 @@ func TestGenAndFreshenSignWithKeyFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFreshenSignedManifestNeedsSignKey runs freshen on a signed manifest
|
|
||||||
// after a file is added. Without --sign-key it must fail, naming the
|
|
||||||
// signer and --sign-key, and leave the manifest as it was; with --sign-key
|
|
||||||
// it must rewrite the manifest, signed.
|
|
||||||
func TestFreshenSignedManifestNeedsSignKey(t *testing.T) {
|
|
||||||
t.Setenv(envSignKeyPassphrase, "")
|
|
||||||
|
|
||||||
secretKey, fingerprint := testSecretKey(t, nil, nil)
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
|
||||||
root := t.TempDir()
|
|
||||||
manifestPath := filepath.Join(root, defaultManifestName)
|
|
||||||
|
|
||||||
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
|
||||||
|
|
||||||
opts := testOpts([]string{
|
|
||||||
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
|
|
||||||
"-o", manifestPath, root,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
||||||
|
|
||||||
before, err := afero.ReadFile(fs, manifestPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
|
||||||
|
|
||||||
opts = testOpts([]string{testApp, cmdFreshen, "-q", manifestPath}, fs)
|
|
||||||
assert.Equal(t, 1, runCLI(opts))
|
|
||||||
assert.Contains(t, testStderr(t, opts), fingerprint)
|
|
||||||
assert.Contains(t, testStderr(t, opts), testFlagSignKey)
|
|
||||||
|
|
||||||
after, err := afero.ReadFile(fs, manifestPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, before, after)
|
|
||||||
|
|
||||||
opts = testOpts([]string{
|
|
||||||
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
||||||
|
|
||||||
opts = testOpts([]string{
|
|
||||||
testApp, cmdCheck, "-q",
|
|
||||||
"--" + flagRequireSignature, fingerprint, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
||||||
assert.Len(t, manifestFiles(t, fs, manifestPath), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFreshenSignsUnchangedTree runs freshen with --sign-key on a tree that
|
|
||||||
// has not changed since gen made its unsigned manifest. freshen must
|
|
||||||
// rewrite the manifest signed by that key. Run again with the same key it
|
|
||||||
// must leave the manifest as it is; with another key it must rewrite it
|
|
||||||
// signed by that one.
|
|
||||||
func TestFreshenSignsUnchangedTree(t *testing.T) {
|
|
||||||
t.Setenv(envSignKeyPassphrase, "")
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
_, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "hello"})
|
|
||||||
|
|
||||||
newKeyFile := func() (string, string) {
|
|
||||||
secretKey, fingerprint := testSecretKey(t, nil, nil)
|
|
||||||
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
|
||||||
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
|
||||||
|
|
||||||
return keyFile, fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
freshenAndCheckSignedBy := func(keyFile, fingerprint string) {
|
|
||||||
opts := testOpts([]string{
|
|
||||||
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
||||||
|
|
||||||
opts = testOpts([]string{
|
|
||||||
testApp, cmdCheck, "-q",
|
|
||||||
"--" + flagRequireSignature, fingerprint, manifestPath,
|
|
||||||
}, fs)
|
|
||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
keyFile, fingerprint := newKeyFile()
|
|
||||||
freshenAndCheckSignedBy(keyFile, fingerprint)
|
|
||||||
|
|
||||||
before, err := afero.ReadFile(fs, manifestPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
freshenAndCheckSignedBy(keyFile, fingerprint)
|
|
||||||
|
|
||||||
after, err := afero.ReadFile(fs, manifestPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, before, after, "manifest already signed by the key was rewritten")
|
|
||||||
|
|
||||||
freshenAndCheckSignedBy(newKeyFile())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key,
|
// TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key,
|
||||||
// with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must
|
// with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must
|
||||||
// fail, naming the variable, and write no manifest.
|
// fail, naming the variable, and write no manifest.
|
||||||
@@ -308,105 +207,3 @@ func TestGenAsksForPassphraseOnTerminal(t *testing.T) {
|
|||||||
}, fs)
|
}, fs)
|
||||||
require.Equal(t, 0, runCLI(check), testStderr(t, check))
|
require.Equal(t, 0, runCLI(check), testStderr(t, check))
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertEmptySignKeyRefused runs gen and freshen, each with flags, on a
|
|
||||||
// directory holding a file and an unsigned manifest. Each must fail,
|
|
||||||
// naming --sign-key, and leave the manifest as it was.
|
|
||||||
func assertEmptySignKeyRefused(t *testing.T, flags ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
manifest := manifestOf(t, map[string][]byte{testFileTxt: []byte("listed")})
|
|
||||||
|
|
||||||
for command, rest := range map[string][]string{
|
|
||||||
cmdGenerate: {"-q", "-f", "-o", testMF, testDir},
|
|
||||||
cmdFreshen: {"-q", testMF},
|
|
||||||
} {
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
||||||
writeTestFile(t, fs, testFile1, "hello")
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644))
|
|
||||||
|
|
||||||
opts := testOpts(slices.Concat([]string{testApp, command}, flags, rest), fs)
|
|
||||||
assert.Equal(t, 1, runCLI(opts), command)
|
|
||||||
assert.Contains(t, testStderr(t, opts), errEmptySignKey.Error(), command)
|
|
||||||
|
|
||||||
content, err := afero.ReadFile(fs, testMF)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, manifest, content, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGenAndFreshenRefuseEmptySignKey runs gen and freshen with --sign-key
|
|
||||||
// given an empty value, as an unset shell variable gives it.
|
|
||||||
func TestGenAndFreshenRefuseEmptySignKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assertEmptySignKeyRefused(t, testFlagSignKey, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGenAndFreshenRefuseEmptySignKeyFromEnvironment runs gen and freshen
|
|
||||||
// with MFER_SIGN_KEY set to an empty string.
|
|
||||||
func TestGenAndFreshenRefuseEmptySignKeyFromEnvironment(t *testing.T) {
|
|
||||||
t.Setenv("MFER_SIGN_KEY", "")
|
|
||||||
|
|
||||||
assertEmptySignKeyRefused(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertEmptyFingerprintRefused runs check on an unsigned manifest, and
|
|
||||||
// fetch of that manifest from a server, each with flags. Each must refuse
|
|
||||||
// the empty fingerprint as malformed, and fetch must request nothing.
|
|
||||||
func assertEmptyFingerprintRefused(t *testing.T, flags ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const want = "invalid fingerprint: must be exactly 40 hex characters, got 0"
|
|
||||||
|
|
||||||
files := map[string][]byte{testFileTxt: []byte("listed")}
|
|
||||||
manifest := manifestOf(t, files)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
||||||
writeTestFile(t, fs, filepath.Join(testDir, testFileTxt), "listed")
|
|
||||||
require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644))
|
|
||||||
|
|
||||||
opts := testOpts(slices.Concat(
|
|
||||||
[]string{testApp, cmdCheck, "-q"}, flags, []string{testMF},
|
|
||||||
), fs)
|
|
||||||
assert.Equal(t, 1, runCLI(opts), cmdCheck)
|
|
||||||
assert.Contains(t, testStderr(t, opts), want, cmdCheck)
|
|
||||||
|
|
||||||
var requests atomic.Int32
|
|
||||||
|
|
||||||
tree := fetchTestHandler(manifest, files)
|
|
||||||
|
|
||||||
server := httptest.NewServer(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requests.Add(1)
|
|
||||||
tree.ServeHTTP(w, r)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
opts = testOpts(slices.Concat(
|
|
||||||
[]string{testApp, cmdFetch, "-q", "--" + flagDest, t.TempDir()},
|
|
||||||
flags, []string{server.URL},
|
|
||||||
), afero.NewOsFs())
|
|
||||||
assert.Equal(t, 1, runCLI(opts), cmdFetch)
|
|
||||||
assert.Contains(t, testStderr(t, opts), want, cmdFetch)
|
|
||||||
assert.Zero(t, requests.Load(), "fetch made a request")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckAndFetchRefuseEmptyRequireSignature runs check and fetch with
|
|
||||||
// --require-signature given an empty value, as an unset shell variable
|
|
||||||
// gives it.
|
|
||||||
func TestCheckAndFetchRefuseEmptyRequireSignature(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assertEmptyFingerprintRefused(t, "--"+flagRequireSignature, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment runs check
|
|
||||||
// and fetch with MFER_REQUIRE_SIGNATURE set to an empty string.
|
|
||||||
func TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment(t *testing.T) {
|
|
||||||
t.Setenv("MFER_REQUIRE_SIGNATURE", "")
|
|
||||||
|
|
||||||
assertEmptyFingerprintRefused(t)
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-32
@@ -34,10 +34,10 @@ var (
|
|||||||
errPathAbsolute = errors.New("is absolute; must be relative")
|
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")
|
||||||
errHashNotSHA256 = errors.New("hash is not a SHA-256 multihash")
|
errHashNotMultihash = errors.New("hash is not a valid multihash")
|
||||||
|
errHashTooShort = errors.New("hash digest is too short")
|
||||||
errDuplicatePath = errors.New("duplicate path")
|
errDuplicatePath = errors.New("duplicate path")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,7 +46,6 @@ 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 {
|
||||||
@@ -74,10 +73,6 @@ 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
|
||||||
@@ -112,28 +107,6 @@ func (m ModTime) Timestamp() *Timestamp {
|
|||||||
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
||||||
type Multihash []byte
|
type Multihash []byte
|
||||||
|
|
||||||
// SHA256Multihash returns the first of entry's hashes that is a SHA-256
|
|
||||||
// multihash, or nil if none is. Loading a manifest refuses an entry that
|
|
||||||
// does not list exactly one, so for a loaded entry it is the only one.
|
|
||||||
func SHA256Multihash(entry *MFFilePath) Multihash {
|
|
||||||
for _, h := range entry.GetHashes() {
|
|
||||||
if isSHA256Multihash(h.GetMultiHash()) {
|
|
||||||
return h.GetMultiHash()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSHA256Multihash reports whether hash is a SHA-256 multihash: the
|
|
||||||
// multihash code for SHA-256, then a 32-byte digest.
|
|
||||||
func isSHA256Multihash(hash []byte) bool {
|
|
||||||
decoded, err := multihash.Decode(hash)
|
|
||||||
|
|
||||||
return err == nil && decoded.Code == multihash.SHA2_256 &&
|
|
||||||
len(decoded.Digest) == sha256.Size
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileHashProgress reports progress during file hashing.
|
// FileHashProgress reports progress during file hashing.
|
||||||
type FileHashProgress struct {
|
type FileHashProgress struct {
|
||||||
BytesRead FileSize // Total bytes read so far for the current file
|
BytesRead FileSize // Total bytes read so far for the current file
|
||||||
@@ -262,7 +235,8 @@ func (b *Builder) FileCount() int {
|
|||||||
// This is useful when the hash is already known (e.g., from an existing manifest).
|
// This is useful when the hash is already known (e.g., from an existing manifest).
|
||||||
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
||||||
// Returns an error if path is invalid or already added, size is negative,
|
// Returns an error if path is invalid or already added, size is negative,
|
||||||
// or hash is not a SHA-256 multihash, the hash every entry must carry.
|
// or hash is not a multihash with a digest of at least 32 bytes, as long
|
||||||
|
// as SHA-256's.
|
||||||
func (b *Builder) AddFileWithHash(
|
func (b *Builder) AddFileWithHash(
|
||||||
path RelFilePath,
|
path RelFilePath,
|
||||||
size FileSize,
|
size FileSize,
|
||||||
@@ -279,8 +253,19 @@ func (b *Builder) AddFileWithHash(
|
|||||||
return errNegativeSize
|
return errNegativeSize
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSHA256Multihash(hash) {
|
decoded, err := multihash.Decode(hash)
|
||||||
return errHashNotSHA256
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", errHashNotMultihash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reader's limit on decoding cost (maxDecodedGrowth) assumes every
|
||||||
|
// hash is at least as long as a SHA-256 multihash, so a manifest of
|
||||||
|
// shorter ones could fail to load.
|
||||||
|
if len(decoded.Digest) < sha256.Size {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %d bytes, at least %d needed",
|
||||||
|
errHashTooShort, len(decoded.Digest), sha256.Size,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := &MFFilePath{
|
entry := &MFFilePath{
|
||||||
|
|||||||
+10
-16
@@ -5,7 +5,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -95,25 +94,23 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
|||||||
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sha512Hash, err := multihash.Encode(make([]byte, sha512.Size), multihash.SHA2_512)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
truncatedHash, err := multihash.Encode(make([]byte, sha256.Size-1), multihash.SHA2_256)
|
truncatedHash, err := multihash.Encode(make([]byte, sha256.Size-1), multihash.SHA2_256)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
hash Multihash
|
hash Multihash
|
||||||
|
want error
|
||||||
}{
|
}{
|
||||||
{"nil hash", nil},
|
{"nil hash", nil, errHashNotMultihash},
|
||||||
{"empty hash", []byte{}},
|
{"empty hash", []byte{}, errHashNotMultihash},
|
||||||
{"one-byte hash", []byte{0x12}},
|
{"one-byte hash", []byte{0x12}, errHashNotMultihash},
|
||||||
// A SHA-256 code and 32-byte length, then only two bytes of digest.
|
// A SHA-256 code and 32-byte length, then only two bytes of digest.
|
||||||
{"malformed multihash", []byte{0x12, 0x20, 0x01, 0x02}},
|
{"malformed multihash", []byte{0x12, 0x20, 0x01, 0x02}, errHashNotMultihash},
|
||||||
{"SHA-1 multihash", sha1Hash},
|
// A valid multihash, but its 20-byte SHA-1 digest is too short.
|
||||||
{"SHA-512 multihash", sha512Hash},
|
{"SHA-1 multihash", sha1Hash, errHashTooShort},
|
||||||
// A SHA-256 code with a 31-byte digest.
|
// A valid multihash whose 31-byte digest is one byte short.
|
||||||
{"31-byte digest", truncatedHash},
|
{"31-byte digest", truncatedHash, errHashTooShort},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -122,7 +119,7 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
|||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, tt.hash)
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, tt.hash)
|
||||||
require.ErrorIs(t, err, errHashNotSHA256)
|
require.ErrorIs(t, err, tt.want)
|
||||||
assert.Equal(t, 0, b.FileCount())
|
assert.Equal(t, 0, b.FileCount())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -289,9 +286,6 @@ 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 {
|
||||||
|
|||||||
+25
-48
@@ -80,8 +80,7 @@ type Checker struct {
|
|||||||
// manifestPaths is a set of paths in the manifest for quick lookup
|
// manifestPaths is a set of paths in the manifest for quick lookup
|
||||||
manifestPaths map[RelFilePath]struct{}
|
manifestPaths map[RelFilePath]struct{}
|
||||||
// manifestInfo is the manifest file, which FindExtraFiles leaves out,
|
// manifestInfo is the manifest file, which FindExtraFiles leaves out,
|
||||||
// matched with os.SameFile. It is nil for a manifest read from
|
// matched with os.SameFile.
|
||||||
// CheckerOptions.ManifestReader.
|
|
||||||
manifestInfo os.FileInfo
|
manifestInfo os.FileInfo
|
||||||
// signature info from the manifest
|
// signature info from the manifest
|
||||||
signature []byte
|
signature []byte
|
||||||
@@ -91,13 +90,8 @@ type Checker struct {
|
|||||||
|
|
||||||
// CheckerOptions configures a Checker.
|
// CheckerOptions configures a Checker.
|
||||||
type CheckerOptions struct {
|
type CheckerOptions struct {
|
||||||
// ManifestPath is the manifest file to check against. It is required
|
// ManifestPath is the manifest file to check against (required).
|
||||||
// unless ManifestReader is given.
|
|
||||||
ManifestPath string
|
ManifestPath string
|
||||||
// ManifestReader, if not nil, is read for the manifest to check against
|
|
||||||
// in place of ManifestPath, such as a manifest downloaded into memory.
|
|
||||||
// FindExtraFiles then has no manifest file to leave out.
|
|
||||||
ManifestReader io.Reader
|
|
||||||
// BasePath is the directory relative to which manifest paths are
|
// BasePath is the directory relative to which manifest paths are
|
||||||
// resolved (required).
|
// resolved (required).
|
||||||
BasePath string
|
BasePath string
|
||||||
@@ -106,10 +100,9 @@ type CheckerOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewChecker creates a new Checker with the given options. It returns an
|
// NewChecker creates a new Checker with the given options. It returns an
|
||||||
// error if opts is nil, the base path is empty, or neither a manifest path
|
// error if opts is nil or either path is empty.
|
||||||
// nor a manifest reader is given.
|
|
||||||
func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
||||||
if opts == nil || (opts.ManifestPath == "" && opts.ManifestReader == nil) {
|
if opts == nil || opts.ManifestPath == "" {
|
||||||
return nil, errManifestPathEmpty
|
return nil, errManifestPathEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,30 +115,12 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
|||||||
fs = afero.NewOsFs()
|
fs = afero.NewOsFs()
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
m, err := NewManifestFromFile(&ManifestFromFileOptions{
|
||||||
m *manifest
|
Path: opts.ManifestPath,
|
||||||
manifestInfo os.FileInfo
|
Fs: fs,
|
||||||
err error
|
})
|
||||||
)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
if opts.ManifestReader != nil {
|
|
||||||
m, err = NewManifestFromReader(opts.ManifestReader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m, err = NewManifestFromFile(&ManifestFromFileOptions{
|
|
||||||
Path: opts.ManifestPath,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
manifestInfo, err = fs.Stat(opts.ManifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abs, err := filepath.Abs(opts.BasePath)
|
abs, err := filepath.Abs(opts.BasePath)
|
||||||
@@ -160,6 +135,11 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
|||||||
manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
|
manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manifestInfo, err := fs.Stat(opts.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Checker{
|
return &Checker{
|
||||||
basePath: AbsFilePath(abs),
|
basePath: AbsFilePath(abs),
|
||||||
files: files,
|
files: files,
|
||||||
@@ -284,12 +264,11 @@ func (c *Checker) Check(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindExtraFiles walks the filesystem and reports the regular files, and
|
// FindExtraFiles walks the filesystem and reports files not in the manifest,
|
||||||
// symlinks to them, that are not in the manifest, hidden files and
|
// hidden files and directories included. The manifest file itself is not
|
||||||
// directories included. The manifest file itself is not reported. Anything
|
// reported. Anything the search cannot read, such as a directory that cannot
|
||||||
// the search cannot read, such as a directory that cannot be listed, is
|
// be listed, is reported with StatusError and the search goes on. Results are
|
||||||
// reported with StatusError and the search goes on. Results are sent to the
|
// sent to the results channel. The channel is closed when done.
|
||||||
// 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)
|
||||||
@@ -339,6 +318,11 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gen never lists a named pipe, a socket or a device.
|
||||||
|
if info.Mode()&(os.ModeNamedPipe|os.ModeSocket|os.ModeDevice) != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// A symlink is compared by what it points to, so a manifest reached
|
// A symlink is compared by what it points to, so a manifest reached
|
||||||
// through one is not reported either.
|
// through one is not reported either.
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
@@ -348,13 +332,6 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// gen lists regular files and symlinks to them only, so a named
|
|
||||||
// pipe, a socket, a device, a symlink to a directory and a broken
|
|
||||||
// symlink are never reported.
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip the manifest file itself, however its path is spelled
|
// 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
|
||||||
|
|||||||
@@ -189,38 +189,6 @@ func TestNewCheckerNilFsUsesOsFs(t *testing.T) {
|
|||||||
assert.Equal(t, StatusOK, (<-results).Status)
|
assert.Equal(t, StatusOK, (<-results).Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewCheckerReadsManifestFromReader gives NewChecker the manifest as a
|
|
||||||
// reader and no manifest path, on a filesystem that holds only the files it
|
|
||||||
// lists: it checks those files and finds nothing unlisted.
|
|
||||||
func TestNewCheckerReadsManifestFromReader(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
files := map[string][]byte{testFile1: []byte("hello")}
|
|
||||||
|
|
||||||
manifestFs := afero.NewMemMapFs()
|
|
||||||
createTestManifest(t, manifestFs, testManifestPath, files)
|
|
||||||
manifestData, err := afero.ReadFile(manifestFs, testManifestPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
createFilesOnDisk(t, fs, files)
|
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
|
||||||
ManifestReader: bytes.NewReader(manifestData),
|
|
||||||
BasePath: testDataDir,
|
|
||||||
Fs: fs,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
results := make(chan Result, 1)
|
|
||||||
require.NoError(t, chk.Check(context.Background(), results, nil))
|
|
||||||
assert.Equal(t, Result{Path: testFile1, Status: StatusOK}, <-results)
|
|
||||||
|
|
||||||
extras := make(chan Result, len(files))
|
|
||||||
require.NoError(t, chk.FindExtraFiles(context.Background(), extras))
|
|
||||||
assert.Empty(t, extras)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
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.
|
||||||
|
|||||||
+5
-39
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
@@ -28,9 +27,7 @@ var (
|
|||||||
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
||||||
errInvalidFileFormat = errors.New("invalid file format")
|
errInvalidFileFormat = errors.New("invalid file format")
|
||||||
errInvalidManifestPath = errors.New("invalid file entry")
|
errInvalidManifestPath = errors.New("invalid file entry")
|
||||||
errEntryMissingHash = errors.New("file entry has no SHA-256 hash")
|
errEntryMissingHash = errors.New("file entry has no hash")
|
||||||
errEntryExtraSHA256Hash = errors.New("file entry has more than one SHA-256 hash")
|
|
||||||
errModeOutOfRange = errors.New("file entry has a mode above 0777")
|
|
||||||
errDecodedTooLarge = errors.New("too much memory needed")
|
errDecodedTooLarge = errors.New("too much memory needed")
|
||||||
errSignerNotSigningKey = errors.New(
|
errSignerNotSigningKey = errors.New(
|
||||||
"signer is not the fingerprint of the key that made the signature")
|
"signer is not the fingerprint of the key that made the signature")
|
||||||
@@ -239,30 +236,6 @@ func forEachBytesField(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateEntryHashes refuses, naming its path, an entry that does not list
|
|
||||||
// exactly one SHA-256 multihash; hashes of other algorithms beside it are
|
|
||||||
// allowed. check and fetch compute only SHA-256, so with none they would
|
|
||||||
// have nothing to check the file against, and with two check would pass a
|
|
||||||
// file that matches either.
|
|
||||||
func validateEntryHashes(entry *MFFilePath) error {
|
|
||||||
sha256Hashes := 0
|
|
||||||
|
|
||||||
for _, h := range entry.GetHashes() {
|
|
||||||
if isSHA256Multihash(h.GetMultiHash()) {
|
|
||||||
sha256Hashes++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case sha256Hashes == 0:
|
|
||||||
return fmt.Errorf("%w: %q", errEntryMissingHash, entry.GetPath())
|
|
||||||
case sha256Hashes > 1:
|
|
||||||
return fmt.Errorf("%w: %q", errEntryExtraSHA256Hash, entry.GetPath())
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *manifest) deserializeInner() error {
|
func (m *manifest) deserializeInner() error {
|
||||||
err := m.validateOuterHeader()
|
err := m.validateOuterHeader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,10 +294,8 @@ func (m *manifest) deserializeInner() error {
|
|||||||
// untrusted .mf. Reject loudly on the first offender rather than
|
// untrusted .mf. Reject loudly on the first offender rather than
|
||||||
// dropping entries, which would let a hostile manifest hide files from a
|
// dropping entries, which would let a hostile manifest hide files from a
|
||||||
// check. A path listed twice is refused too: check would check the one
|
// check. A path listed twice is refused too: check would check the one
|
||||||
// file against both entries. So is an entry that does not list exactly
|
// file against both entries. So is an entry with no hash: there is
|
||||||
// one SHA-256 hash, and one that records a mode above 0777, such as one
|
// nothing to check the file against.
|
||||||
// with setuid: a mode holds the permission bits alone, which are all
|
|
||||||
// check compares and fetch sets.
|
|
||||||
seen := make(map[string]bool, len(m.pbInner.GetFiles()))
|
seen := make(map[string]bool, len(m.pbInner.GetFiles()))
|
||||||
|
|
||||||
for _, f := range m.pbInner.GetFiles() {
|
for _, f := range m.pbInner.GetFiles() {
|
||||||
@@ -333,13 +304,8 @@ func (m *manifest) deserializeInner() error {
|
|||||||
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
|
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateEntryHashes(f)
|
if len(f.GetHashes()) == 0 {
|
||||||
if err != nil {
|
return fmt.Errorf("%w: %q", errEntryMissingHash, f.GetPath())
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if f.GetMode() > uint32(os.ModePerm) {
|
|
||||||
return fmt.Errorf("%w: %q (%#o)", errModeOutOfRange, f.GetPath(), f.GetMode())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if seen[f.GetPath()] {
|
if seen[f.GetPath()] {
|
||||||
|
|||||||
+25
-153
@@ -5,11 +5,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"uuid"
|
"uuid"
|
||||||
@@ -83,8 +80,6 @@ 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"},
|
||||||
@@ -170,154 +165,40 @@ func TestDeserializeRefusesPathListedTwice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A file entry must list exactly one SHA-256 hash, and may list hashes of
|
// A manifest holding a file entry with no hash is refused as it is loaded,
|
||||||
// other algorithms beside it. An entry with none, even one that lists a
|
// naming the path: there is nothing to check the file against.
|
||||||
// SHA-512 hash, is refused as it is loaded, and so is an entry with two;
|
func TestDeserializeRefusesEntryWithNoHash(t *testing.T) {
|
||||||
// each refusal names the path. An entry listing a SHA-512 hash and then a
|
|
||||||
// SHA-256 one loads with both.
|
|
||||||
func TestDeserializeRequiresExactlyOneSHA256Hash(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
sha256Hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
id := uuid.NewV4()
|
||||||
|
inner, err := proto.Marshal(&MFFile{
|
||||||
|
Version: MFFile_VERSION_ONE,
|
||||||
|
Files: []*MFFilePath{{Path: "x", Size: 1}},
|
||||||
|
Uuid: id[:],
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
otherSHA256Hash, err := multihash.Encode(
|
_, err = NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
||||||
bytes.Repeat([]byte{1}, sha256.Size), multihash.SHA2_256)
|
require.ErrorIs(t, err, errEntryMissingHash)
|
||||||
require.NoError(t, err)
|
require.EqualError(t, err, `file entry has no hash: "x"`)
|
||||||
|
|
||||||
sha512Hash, err := multihash.Encode(make([]byte, sha512.Size), multihash.SHA2_512)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
hashes [][]byte
|
|
||||||
want error // nil for an entry that loads
|
|
||||||
}{
|
|
||||||
{"no hash", nil, errEntryMissingHash},
|
|
||||||
{"SHA-512 hash only", [][]byte{sha512Hash}, errEntryMissingHash},
|
|
||||||
{
|
|
||||||
"two SHA-256 hashes",
|
|
||||||
[][]byte{sha256Hash, otherSHA256Hash},
|
|
||||||
errEntryExtraSHA256Hash,
|
|
||||||
},
|
|
||||||
{"SHA-512 hash then SHA-256 hash", [][]byte{sha512Hash, sha256Hash}, nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
entry := &MFFilePath{Path: "x", Size: 1}
|
|
||||||
for _, h := range tt.hashes {
|
|
||||||
entry.Hashes = append(entry.Hashes, &MFFileChecksum{MultiHash: h})
|
|
||||||
}
|
|
||||||
|
|
||||||
id := uuid.NewV4()
|
|
||||||
inner, err := proto.Marshal(&MFFile{
|
|
||||||
Version: MFFile_VERSION_ONE,
|
|
||||||
Files: []*MFFilePath{entry},
|
|
||||||
Uuid: id[:],
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
|
||||||
if tt.want == nil {
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, m.Files(), 1)
|
|
||||||
assert.Len(t, m.Files()[0].GetHashes(), len(tt.hashes))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, tt.want)
|
|
||||||
assert.Contains(t, err.Error(), `"x"`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A file entry that records a mode with more than the permission bits is
|
// Entries of a path, an empty hash, an empty MIME type and empty modification
|
||||||
// refused as it is loaded, naming the path and the mode: once with setuid as
|
// and change times are counted at 432 bytes each (176 + 112 + 16 + 64 + 64)
|
||||||
// Unix writes it (04755) and once as Go keeps it in os.FileMode. An entry
|
// and take 16 bytes plus the path to encode. A 37-character path makes that
|
||||||
// recording 0777 loads.
|
// 53 bytes, about 8.2 times: refused, and leaving any one of the five
|
||||||
func TestDeserializeRefusesModeOutsidePermissionBits(t *testing.T) {
|
// uncounted, even the MIME type, brings it under 8. A 39-character path makes
|
||||||
t.Parallel()
|
// 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.
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mode uint32
|
|
||||||
want string // "" for an entry that loads
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"setuid as Unix writes it",
|
|
||||||
0o4755,
|
|
||||||
`file entry has a mode above 0777: "x" (04755)`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"setuid as Go keeps it",
|
|
||||||
uint32(os.ModeSetuid | 0o755),
|
|
||||||
`file entry has a mode above 0777: "x" (040000755)`,
|
|
||||||
},
|
|
||||||
{"permission bits only", 0o777, ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id := uuid.NewV4()
|
|
||||||
inner, err := proto.Marshal(&MFFile{
|
|
||||||
Version: MFFile_VERSION_ONE,
|
|
||||||
Files: []*MFFilePath{{
|
|
||||||
Path: "x",
|
|
||||||
Size: 1,
|
|
||||||
Hashes: []*MFFileChecksum{{MultiHash: hash}},
|
|
||||||
Mode: tt.mode,
|
|
||||||
}},
|
|
||||||
Uuid: id[:],
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
|
|
||||||
if tt.want == "" {
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, m.Files(), 1)
|
|
||||||
assert.Equal(t, tt.mode, m.Files()[0].GetMode())
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errModeOutOfRange)
|
|
||||||
require.EqualError(t, err, tt.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entries of a path, a SHA-256 hash, an empty MIME type and empty
|
|
||||||
// modification and change times are counted at 432 bytes each (176 + 112 +
|
|
||||||
// 16 + 64 + 64) and take 52 bytes plus the path to encode. A one-character
|
|
||||||
// path makes that 53 bytes, about 8.2 times: refused, and leaving any one of
|
|
||||||
// the five uncounted, even the MIME type, brings it under 8. A two-character
|
|
||||||
// path makes it 54 bytes, 8 times exactly: loaded. There is one entry for
|
|
||||||
// each base-36 digit, and each entry's path is its number in base 36, padded
|
|
||||||
// with zeros to that length, since a manifest lists a path only once.
|
|
||||||
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
checksum, err := proto.Marshal(&MFFileChecksum{MultiHash: hash})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
pathLen int
|
pathLen int
|
||||||
refused bool
|
refused bool
|
||||||
}{
|
}{
|
||||||
{1, true},
|
{37, true},
|
||||||
{2, false},
|
{39, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -330,14 +211,11 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
|||||||
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[:])
|
||||||
|
|
||||||
for i := range 36 {
|
for i := range 1000 {
|
||||||
path := strconv.FormatInt(int64(i), 36)
|
|
||||||
path = strings.Repeat("0", tt.pathLen-len(path)) + path
|
|
||||||
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
||||||
entry = protowire.AppendString(entry, path)
|
entry = protowire.AppendString(entry, fmt.Sprintf("%0*d", tt.pathLen, i))
|
||||||
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||||
entry = protowire.AppendBytes(entry, checksum)
|
entry = protowire.AppendBytes(entry, nil)
|
||||||
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
|
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
|
||||||
entry = protowire.AppendBytes(entry, nil)
|
entry = protowire.AppendBytes(entry, nil)
|
||||||
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
|
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
|
||||||
@@ -367,16 +245,10 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
|
|||||||
unknown := protowire.AppendTag(nil, 99, protowire.BytesType) // in no message
|
unknown := protowire.AppendTag(nil, 99, protowire.BytesType) // in no message
|
||||||
unknown = protowire.AppendBytes(unknown, []byte("not known"))
|
unknown = protowire.AppendBytes(unknown, []byte("not known"))
|
||||||
|
|
||||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
checksum, err := proto.Marshal(&MFFileChecksum{MultiHash: hash})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
entry := protowire.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.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||||
entry = protowire.AppendBytes(entry, checksum)
|
entry = protowire.AppendBytes(entry, nil)
|
||||||
entry = append(entry, unknown...)
|
entry = append(entry, unknown...)
|
||||||
|
|
||||||
id := uuid.NewV4()
|
id := uuid.NewV4()
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"uuid"
|
"uuid"
|
||||||
|
|
||||||
@@ -55,38 +52,3 @@ func TestReadAtMost(t *testing.T) {
|
|||||||
"file 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()))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -62,12 +62,6 @@ 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()
|
||||||
|
|||||||
@@ -47,23 +47,6 @@ 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.
|
||||||
|
|||||||
+2
-2
@@ -333,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 ".." or "." segments, and
|
// be relative (no leading /), contain no ".." segments, and no
|
||||||
// no empty segments (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:
|
||||||
|
|||||||
+2
-2
@@ -49,8 +49,8 @@ message MFFileOuter {
|
|||||||
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 ".." or "." segments, and
|
// be relative (no leading /), contain no ".." segments, and no
|
||||||
// no empty segments (no "//").
|
// empty segments (no "//").
|
||||||
string path = 1;
|
string path = 1;
|
||||||
int64 size = 2;
|
int64 size = 2;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1668b2f6b8639a9213873474e58dbacb3a4ef77cc27c42253cad8dd076e5f206 mf.proto
|
4f8ea5276a8e66590e71ed154a9176b0f68ffa9ab69d8de5aff0e0d9b731164d mf.proto
|
||||||
|
|||||||
+3
-39
@@ -57,9 +57,7 @@ var (
|
|||||||
"signature must hold exactly one signature")
|
"signature must hold exactly one signature")
|
||||||
errNotOneArmoredBlock = errors.New(
|
errNotOneArmoredBlock = errors.New(
|
||||||
"must be exactly one armored block and nothing else")
|
"must be exactly one armored block and nothing else")
|
||||||
errMalformedArmor = errors.New("armor is malformed")
|
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.
|
// SigningOptions holds the key a manifest is signed with.
|
||||||
@@ -83,19 +81,6 @@ func SecretKeyIsProtected(secretKey []byte) (bool, error) {
|
|||||||
return isProtected(key), nil
|
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
|
// 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
|
// 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
|
// expired or been revoked, and opts.Passphrase must unlock it when it is
|
||||||
@@ -260,9 +245,8 @@ func fingerprint(key *openpgp.Entity) string {
|
|||||||
|
|
||||||
// verifySignature checks that signature is one good, unexpired OpenPGP
|
// verifySignature checks that signature is one good, unexpired OpenPGP
|
||||||
// signature over data, made by the one primary key in pubKey or one of its
|
// 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
|
// subkeys, and returns that primary key's fingerprint. signature and pubKey
|
||||||
// fingerprint. The key may have expired since. signature and pubKey may
|
// may each be armored or binary.
|
||||||
// each be armored or binary.
|
|
||||||
func verifySignature(data, signature, pubKey []byte) (string, error) {
|
func verifySignature(data, signature, pubKey []byte) (string, error) {
|
||||||
key, err := readOneKey(pubKey, "embedded public key block", true)
|
key, err := readOneKey(pubKey, "embedded public key block", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -297,26 +281,6 @@ func verifySignature(data, signature, pubKey []byte) (string, error) {
|
|||||||
return "", fmt.Errorf("verify signature: %w", err)
|
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
|
return fingerprint(key), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-160
@@ -4,7 +4,6 @@ package mfer
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto"
|
|
||||||
"crypto/dsa" //nolint:staticcheck // SA1019: tests need a DSA key to refuse
|
"crypto/dsa" //nolint:staticcheck // SA1019: tests need a DSA key to refuse
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
@@ -177,25 +176,6 @@ func TestBuilderWithSigning(t *testing.T) {
|
|||||||
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
"-----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) {
|
func TestScannerWithSigning(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -281,21 +261,6 @@ func TestSecretKeyIsProtectedWithoutPassphrase(t *testing.T) {
|
|||||||
assert.False(t, protected)
|
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,
|
// 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
|
// as gpg writes it for a user ID that two keys have. It names no one key
|
||||||
// to sign with, so signing must fail.
|
// to sign with, so signing must fail.
|
||||||
@@ -454,9 +419,8 @@ func TestVerifySignature(t *testing.T) {
|
|||||||
|
|
||||||
// TestVerifySignatureKeyExpiredSince verifies two signatures made in 2020
|
// TestVerifySignatureKeyExpiredSince verifies two signatures made in 2020
|
||||||
// by a key that expired a day after it was made. A signature with no
|
// 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
|
// expiry time is still good. One that expired an hour after it was made is
|
||||||
// expired a day after it was made. One that expired an hour after it was
|
// refused, although the key has expired too.
|
||||||
// made is refused, although the key has expired too.
|
|
||||||
func TestVerifySignatureKeyExpiredSince(t *testing.T) {
|
func TestVerifySignatureKeyExpiredSince(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -484,132 +448,10 @@ func TestVerifySignatureKeyExpiredSince(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, fingerprint(key), signer)
|
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)
|
_, err = verifySignature(data, expiringSig.Bytes(), pubKey)
|
||||||
require.ErrorIs(t, err, pgperrors.ErrSignatureExpired)
|
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) {
|
func TestManifestSignatureVerification(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user