Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d90bef8580 |
+2
-62
@@ -1,64 +1,4 @@
|
|||||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
|
||||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
|
||||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
|
||||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
|
||||||
# `certs/server.key` still ship while this file reads as solved. Only
|
|
||||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
|
||||||
# into .gitignore, where `**/` is wrong.
|
|
||||||
#
|
|
||||||
# Matching is case-sensitive, so secrets use character ranges rather
|
|
||||||
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
|
||||||
#
|
|
||||||
# Extend with this repo's own host-built artifacts, written anchored:
|
|
||||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
|
||||||
# deletes the package directory from the context.
|
|
||||||
|
|
||||||
# .git is sent without its config. Without a VERSION build argument the
|
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
|
||||||
# password in a remote URL or the token the CI checkout step stores there.
|
|
||||||
.git/config
|
|
||||||
|
|
||||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
|
||||||
# Anchored because it occurs once where agents run at the repo root.
|
|
||||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
|
||||||
# `services/api/.claude/` and must add its own anchored entry.
|
|
||||||
.claude
|
|
||||||
|
|
||||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
|
||||||
# convention. Re-include a committed template with a negation if the
|
|
||||||
# build needs one: `!docs/example.env`.
|
|
||||||
**/*.[eE][nN][vV]
|
|
||||||
**/.[eE][nN][vV].*
|
|
||||||
**/.[eE][nN][vV][rR][cC]
|
|
||||||
|
|
||||||
# Private keys and the bundles carrying them. Public certificates
|
|
||||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
|
||||||
**/*.[pP][eE][mM]
|
|
||||||
**/*.[kK][eE][yY]
|
|
||||||
**/*.[pP]12
|
|
||||||
**/*.[pP][fF][xX]
|
|
||||||
**/[iI][dD]_[rR][sS][aA]
|
|
||||||
**/[iI][dD]_[dD][sS][aA]
|
|
||||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
|
||||||
**/[iI][dD]_[eE][dD]25519
|
|
||||||
|
|
||||||
# Dependencies: restored inside the image, never copied in.
|
|
||||||
**/node_modules
|
|
||||||
|
|
||||||
# OS metadata.
|
|
||||||
**/.DS_Store
|
|
||||||
**/Thumbs.db
|
|
||||||
|
|
||||||
# Editor state: never a build input, and it churns COPY.
|
|
||||||
**/*.swp
|
|
||||||
**/*.swo
|
|
||||||
**/*~
|
|
||||||
**/*.bak
|
|
||||||
**/.idea
|
|
||||||
**/.vscode
|
|
||||||
**/*.sublime-*
|
|
||||||
|
|
||||||
# This repo's own host-built archives (Makefile).
|
|
||||||
*.tmp
|
*.tmp
|
||||||
*.dockerimage
|
*.dockerimage
|
||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
|||||||
+1
-20
@@ -48,26 +48,7 @@ COPY . .
|
|||||||
RUN touch mfer/mf.pb.go
|
RUN touch mfer/mf.pb.go
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
RUN cd cmd/mfer && go build -tags urfave_cli_no_docs -o /mfer .
|
||||||
# A build context sent as a tar archive, as upaas sends it, keeps its files'
|
|
||||||
# owners, and git refuses to read a checkout owned by another user.
|
|
||||||
RUN git config --system --add safe.directory /src
|
|
||||||
|
|
||||||
# The revision `mfer version` prints, stamped into main.Gitrev: the VERSION
|
|
||||||
# build argument when one is given (script/docker passes one), otherwise
|
|
||||||
# `git describe --tags --always` of the .git the build context carries: the
|
|
||||||
# tag on a tagged commit, tag-N-gHASH on a commit after one, the short commit
|
|
||||||
# when no tag is reachable. git ships in this base image. A context that
|
|
||||||
# carries .git and still yields no version fails the build.
|
|
||||||
ARG VERSION
|
|
||||||
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
|
||||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
|
||||||
[ "$version" = unknown ]; }; then \
|
|
||||||
echo "no version could be derived although the build context carries .git" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
cd cmd/mfer && \
|
|
||||||
go build -tags urfave_cli_no_docs -ldflags "-X main.Gitrev=$version" -o /mfer .
|
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /mfer /mfer
|
COPY --from=builder /mfer /mfer
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# mfer
|
# mfer
|
||||||
|
|
||||||
[mfer](https://git.eeqj.de/sneak/mfer) is a reference implementation library and
|
[mfer](https://git.eeqj.de/sneak/mfer) is a [WTFPL](https://wtfpl.net)-licensed
|
||||||
thin wrapper command-line utility written in [Go](https://golang.org) and first
|
(public domain) [Go](https://golang.org) library and command-line tool by
|
||||||
published in 2022 under the [WTFPL](https://wtfpl.net) (public domain) license.
|
[@sneak](https://sneak.berlin) that specifies and generates `.mf` manifest files
|
||||||
It specifies and generates `.mf` manifest files over a directory tree of files
|
over a directory tree to encapsulate metadata about the files — such as
|
||||||
to encapsulate metadata about them (such as cryptographic checksums or
|
cryptographic checksums and signatures over same — to aid in archiving,
|
||||||
signatures over same) to aid in archiving, downloading, and streaming, or
|
downloading, streaming, and mirroring. It was first published in 2022. The
|
||||||
mirroring. The manifest files' data is serialized with Google's
|
manifest files' data is serialized with Google's
|
||||||
[protobuf serialization format](https://developers.google.com/protocol-buffers).
|
[protobuf serialization format](https://developers.google.com/protocol-buffers).
|
||||||
The structure of these files can be found
|
The structure of these files can be found
|
||||||
[in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
[in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
||||||
@@ -21,6 +21,36 @@ This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
|
|||||||
as a de-facto standard and be incorporated into other software. A compatible
|
as a de-facto standard and be incorporated into other software. A compatible
|
||||||
javascript library is planned.
|
javascript library is planned.
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
`mfer` builds from source with a Go 1.23+ toolchain. The generated protobuf code
|
||||||
|
is committed, so no `protoc` toolchain is required:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.eeqj.de/sneak/mfer.git
|
||||||
|
cd mfer
|
||||||
|
go build -o bin/mfer ./cmd/mfer
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a manifest for a directory tree, verify it later, and fetch a published
|
||||||
|
tree by URL:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Write .index.mf describing every file under the current directory.
|
||||||
|
bin/mfer gen .
|
||||||
|
|
||||||
|
# Verify the files on disk against the manifest. Exits nonzero if any file
|
||||||
|
# is missing or corrupted.
|
||||||
|
bin/mfer check .index.mf
|
||||||
|
|
||||||
|
# Download and cryptographically verify a tree published over HTTP: mfer
|
||||||
|
# fetches <url>/index.mf, then downloads every file it lists.
|
||||||
|
bin/mfer fetch https://example.com/tree/
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `bin/mfer help` for the full command list, or `bin/mfer <command> --help`
|
||||||
|
for a single command's options.
|
||||||
|
|
||||||
# Build Status
|
# Build Status
|
||||||
|
|
||||||
CI runs via `script/cibuild` (`docker build .`), which executes `make check`
|
CI runs via `script/cibuild` (`docker build .`), which executes `make check`
|
||||||
@@ -81,7 +111,9 @@ Any changes submitted to this project must also be
|
|||||||
See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards,
|
See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards,
|
||||||
tooling requirements, and workflow conventions.
|
tooling requirements, and workflow conventions.
|
||||||
|
|
||||||
# Problem Statement
|
# Rationale
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
Given a plain URL, there is no standard way to safely and programmatically
|
Given a plain URL, there is no standard way to safely and programmatically
|
||||||
download everything "under" that URL path. `wget -r` can traverse directory
|
download everything "under" that URL path. `wget -r` can traverse directory
|
||||||
@@ -105,7 +137,7 @@ Real issues I face:
|
|||||||
- when I download a large file via HTTP, I have no way of knowing if the file
|
- when I download a large file via HTTP, I have no way of knowing if the file
|
||||||
content is what it's supposed to be
|
content is what it's supposed to be
|
||||||
|
|
||||||
# Proposed Solution
|
## The solution
|
||||||
|
|
||||||
A standard, a manifest file format, and a tool for generating same.
|
A standard, a manifest file format, and a tool for generating same.
|
||||||
|
|
||||||
@@ -137,6 +169,27 @@ The manifest file would do several important things:
|
|||||||
- maybe a bittorrent chunklist for torrent client compatibility? perhaps a
|
- maybe a bittorrent chunklist for torrent client compatibility? perhaps a
|
||||||
top-level infohash for the whole manifest?
|
top-level infohash for the whole manifest?
|
||||||
|
|
||||||
|
# Design
|
||||||
|
|
||||||
|
The repository is split into a reusable library and a thin command-line wrapper
|
||||||
|
around it.
|
||||||
|
|
||||||
|
- `mfer/` is the reusable library and the heart of the project: it defines the
|
||||||
|
manifest format and implements building, scanning, checking, serialization,
|
||||||
|
and signing. The protobuf schema is `mfer/mf.proto`, and the generated code it
|
||||||
|
produces (`mfer/mf.pb.go`) is committed alongside it so the library builds
|
||||||
|
with `go get` and needs no `protoc` toolchain.
|
||||||
|
- `internal/cli/` holds the command implementations — `gen`, `check`, `freshen`,
|
||||||
|
`export`, `list`, and `fetch` — that wire the library to the command-line
|
||||||
|
interface.
|
||||||
|
- `internal/log/` provides the logging used across the commands.
|
||||||
|
- `internal/bork/` provides error-handling support.
|
||||||
|
- `cmd/mfer/` is the entrypoint: its `main` package assembles the pieces above
|
||||||
|
into the `mfer` binary.
|
||||||
|
|
||||||
|
Everything under `internal/` is private to this repository; only the `mfer/`
|
||||||
|
package is intended for import by other software.
|
||||||
|
|
||||||
# Design Goals
|
# Design Goals
|
||||||
|
|
||||||
- Replace SHASUMS/SHASUMS.asc files
|
- Replace SHASUMS/SHASUMS.asc files
|
||||||
@@ -462,9 +515,9 @@ proto `go_package` option. Which is canonical?
|
|||||||
- Issues:
|
- Issues:
|
||||||
[https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues)
|
[https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues)
|
||||||
|
|
||||||
# Authors
|
# Author
|
||||||
|
|
||||||
- [@sneak <sneak@sneak.berlin>](mailto:sneak@sneak.berlin)
|
- [@sneak](https://sneak.berlin)
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
|
|||||||
@@ -24,28 +24,11 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-03: every gpg run is killed after one minute or when its caller's
|
- 2026-09-21: added the required README sections — named author, license, and
|
||||||
context ends, and a timeout reads as "gpg timed out" under the failing
|
category in the Description first line; added Getting Started (verified
|
||||||
operation; `Builder.Build` and `Checker.ExtractEmbeddedSigningKeyFP` take a
|
install/usage block), Rationale (folding in Problem Statement and Proposed
|
||||||
context, which reaches gpg (#62)
|
Solution), and a Design section for the package layout; renamed Authors to
|
||||||
- 2026-10-03: `fetch` removes whatever sits at a file's temp name and then
|
Author (#75)
|
||||||
creates the temp file only if that name is free, so a hard link left there
|
|
||||||
cannot make it write into a file outside the destination directory (#115)
|
|
||||||
- 2026-10-03: `fetch` refuses any manifest path that runs through a symlink
|
|
||||||
already in the destination directory, checked before each of its writes
|
|
||||||
(directories, temp file, rename), so such a symlink cannot send a write
|
|
||||||
outside it (#86)
|
|
||||||
- 2026-10-02: a plain `docker build .` of a clone now stamps the tag or short
|
|
||||||
commit into `mfer version` instead of nothing: `.dockerignore` sends `.git`
|
|
||||||
(not `.git/config`), and the build stage takes the `VERSION` build argument,
|
|
||||||
otherwise `git describe --tags --always`, failing if `.git` is present and no
|
|
||||||
version comes out. `script/docker` is the canonical copy, which passes
|
|
||||||
`VERSION`; `bin/gitrev.sh` uses `--tags` too (#112)
|
|
||||||
- 2026-09-21: validate manifest entry paths on deserialize so untrusted `.mf`
|
|
||||||
files cannot make `Checker` stat or read outside `basePath` (#61)
|
|
||||||
- 2026-09-21: rewrote `script/test` to the canonical pattern (30s timeout,
|
|
||||||
`-race -cover`, quiet-first with verbose-on-failure rerun) and fixed the
|
|
||||||
process-global logger data race it surfaced (#67)
|
|
||||||
- 2026-09-21: added the canonical `.editorconfig`, made `.gitignore` cover
|
- 2026-09-21: added the canonical `.editorconfig`, made `.gitignore` cover
|
||||||
secrets, OS, editor, and Go artifacts, and removed the dead Drone CI
|
secrets, OS, editor, and Go artifacts, and removed the dead Drone CI
|
||||||
references from `.gitignore` and `bin/gitrev.sh` (#72)
|
references from `.gitignore` and `bin/gitrev.sh` (#72)
|
||||||
@@ -87,6 +70,7 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
|
|||||||
- Move FORMAT.md from repo root to docs/ and update the AGENTS.md reference
|
- Move FORMAT.md from repo root to docs/ and update the AGENTS.md reference
|
||||||
- Pin Makefile-installed Go tools (`protoc-gen-go@v1.28.1`,
|
- Pin Makefile-installed Go tools (`protoc-gen-go@v1.28.1`,
|
||||||
`golangci-lint@v2.12.2`) by module hash, not mutable tag
|
`golangci-lint@v2.12.2`) by module hash, not mutable tag
|
||||||
|
- Set `make test` timeout to 30s (currently 10s)
|
||||||
- Add explicit README "Rationale" heading (content exists under other
|
- Add explicit README "Rationale" heading (content exists under other
|
||||||
names); name the author in the README Description first line
|
names); name the author in the README Description first line
|
||||||
- Reconcile root-level AGENTS.md with directory-hygiene policy (keep or
|
- Reconcile root-level AGENTS.md with directory-hygiene policy (keep or
|
||||||
@@ -116,7 +100,8 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
|
|||||||
- Add decompression size limit via io.LimitReader in deserializeInner()
|
- Add decompression size limit via io.LimitReader in deserializeInner()
|
||||||
- Fix errors.Is dead code in checker; make AddFile verify totalRead == size
|
- Fix errors.Is dead code in checker; make AddFile verify totalRead == size
|
||||||
- Export manifest type or define a public interface (pending)
|
- Export manifest type or define a public interface (pending)
|
||||||
- Replace GPG subprocess with pure-Go crypto (pending)
|
- Replace GPG subprocess with pure-Go crypto (pending); add timeouts to
|
||||||
|
remaining subprocess calls
|
||||||
- CLI:
|
- CLI:
|
||||||
- Kebab-case primary flag names; fix fetch URL construction with
|
- Kebab-case primary flag names; fix fetch URL construction with
|
||||||
url.JoinPath; add http.Client timeout and retry with backoff to fetch;
|
url.JoinPath; add http.Client timeout and retry with backoff to fetch;
|
||||||
|
|||||||
+1
-1
@@ -3,5 +3,5 @@
|
|||||||
if [[ ! -z "$GITREV" ]]; then
|
if [[ ! -z "$GITREV" ]]; then
|
||||||
echo $GITREV
|
echo $GITREV
|
||||||
else
|
else
|
||||||
git describe --tags --always --dirty=-dirty
|
git describe --always --dirty=-dirty
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -127,9 +126,7 @@ func (mfa *CLIApp) fetchManifestToTemp(url string) (string, error) {
|
|||||||
|
|
||||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||||
// against the manifest's embedded signing key.
|
// against the manifest's embedded signing key.
|
||||||
func verifyRequiredSigner(
|
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||||
ctx context.Context, chk *mfer.Checker, requiredSigner string,
|
|
||||||
) error {
|
|
||||||
// Validate fingerprint format: must be exactly 40 hex characters
|
// Validate fingerprint format: must be exactly 40 hex characters
|
||||||
if len(requiredSigner) != fingerprintHexLen {
|
if len(requiredSigner) != fingerprintHexLen {
|
||||||
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
||||||
@@ -148,7 +145,7 @@ func verifyRequiredSigner(
|
|||||||
// Extract fingerprint from the embedded public key (not from the
|
// Extract fingerprint from the embedded public key (not from the
|
||||||
// signer field). This validates the key is importable and gets its
|
// signer field). This validates the key is importable and gets its
|
||||||
// actual fingerprint.
|
// actual fingerprint.
|
||||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(ctx)
|
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"failed to extract fingerprint from embedded signing key: %w", err)
|
"failed to extract fingerprint from embedded signing key: %w", err)
|
||||||
@@ -306,7 +303,7 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
|||||||
// Check signature requirement
|
// Check signature requirement
|
||||||
requiredSigner := ctx.String("require-signature")
|
requiredSigner := ctx.String("require-signature")
|
||||||
if requiredSigner != "" {
|
if requiredSigner != "" {
|
||||||
err = verifyRequiredSigner(ctx.Context, chk, requiredSigner)
|
err = verifyRequiredSigner(chk, requiredSigner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func TestHelpCommand(t *testing.T) {
|
|||||||
stdout := testStdout(t, opts)
|
stdout := testStdout(t, opts)
|
||||||
assert.Contains(t, stdout, cmdGenerate)
|
assert.Contains(t, stdout, cmdGenerate)
|
||||||
assert.Contains(t, stdout, cmdCheck)
|
assert.Contains(t, stdout, cmdCheck)
|
||||||
assert.Contains(t, stdout, cmdFetch)
|
assert.Contains(t, stdout, "fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCommand(t *testing.T) {
|
func TestGenerateCommand(t *testing.T) {
|
||||||
@@ -679,13 +679,11 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data
|
rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data
|
||||||
|
|
||||||
// Create many small files with random names so the manifest has many
|
// Create many small files with random names to generate a ~1MB manifest
|
||||||
// entries and random single-byte flips land at varied offsets. Each
|
// Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
|
||||||
// manifest entry is roughly 50-60 bytes. Kept modest so the suite stays
|
|
||||||
// within its wall-clock budget under -race.
|
|
||||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||||
|
|
||||||
numFiles := 1500
|
numFiles := 20000
|
||||||
for range numFiles {
|
for range numFiles {
|
||||||
// Generate random filename
|
// Generate random filename
|
||||||
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat",
|
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat",
|
||||||
@@ -701,11 +699,11 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
|
|||||||
exitCode := runCLI(opts)
|
exitCode := runCLI(opts)
|
||||||
require.Equal(t, 0, exitCode, "generate should succeed")
|
require.Equal(t, 0, exitCode, "generate should succeed")
|
||||||
|
|
||||||
// Read the valid manifest and verify it has real size.
|
// Read the valid manifest and verify it's approximately 1MB
|
||||||
validManifest, err := afero.ReadFile(fs, testManifest)
|
validManifest, err := afero.ReadFile(fs, testManifest)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.GreaterOrEqual(t, len(validManifest), 64*1024,
|
require.GreaterOrEqual(t, len(validManifest), 1024*1024,
|
||||||
"manifest should be at least 64KB, got %d bytes", len(validManifest))
|
"manifest should be at least 1MB, got %d bytes", len(validManifest))
|
||||||
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
|
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
|
||||||
|
|
||||||
// First corruption: truncate the manifest
|
// First corruption: truncate the manifest
|
||||||
@@ -728,8 +726,8 @@ func TestCheckDetectsManifestCorruption(t *testing.T) {
|
|||||||
exitCode = runCLI(opts)
|
exitCode = runCLI(opts)
|
||||||
require.Equal(t, 0, exitCode, "check should pass with valid manifest")
|
require.Equal(t, 0, exitCode, "check should pass with valid manifest")
|
||||||
|
|
||||||
// Now do 100 random corruption iterations
|
// Now do 500 random corruption iterations
|
||||||
for i := range 100 {
|
for i := range 500 {
|
||||||
// Corrupt: write a random byte at a random offset
|
// Corrupt: write a random byte at a random offset
|
||||||
corrupted := make([]byte, len(validManifest))
|
corrupted := make([]byte, len(validManifest))
|
||||||
copy(corrupted, validManifest)
|
copy(corrupted, validManifest)
|
||||||
|
|||||||
+9
-66
@@ -36,11 +36,6 @@ const (
|
|||||||
// traversal bit for group and other must stay set.
|
// traversal bit for group and other must stay set.
|
||||||
dirPerms os.FileMode = 0o755
|
dirPerms os.FileMode = 0o755
|
||||||
|
|
||||||
// filePerms is the permission mode, before the umask, for downloaded
|
|
||||||
// files. It is the mode os.Create uses; like dirPerms, it keeps group
|
|
||||||
// and other read access.
|
|
||||||
filePerms os.FileMode = 0o666
|
|
||||||
|
|
||||||
// Bitrate unit thresholds in bits per second.
|
// Bitrate unit thresholds in bits per second.
|
||||||
bpsPerGbps = 1e9
|
bpsPerGbps = 1e9
|
||||||
bpsPerMbps = 1e6
|
bpsPerMbps = 1e6
|
||||||
@@ -58,9 +53,6 @@ var (
|
|||||||
// errPathTraversal indicates a manifest path escaping the target
|
// errPathTraversal indicates a manifest path escaping the target
|
||||||
// directory.
|
// directory.
|
||||||
errPathTraversal = errors.New("path traversal not allowed")
|
errPathTraversal = errors.New("path traversal not allowed")
|
||||||
// errSymlinkInPath indicates a manifest path running through a
|
|
||||||
// symlink that already exists in the target directory.
|
|
||||||
errSymlinkInPath = errors.New("symlink in path not allowed")
|
|
||||||
// errSizeMismatch indicates a downloaded file with an unexpected
|
// errSizeMismatch indicates a downloaded file with an unexpected
|
||||||
// size.
|
// size.
|
||||||
errSizeMismatch = errors.New("size mismatch")
|
errSizeMismatch = errors.New("size mismatch")
|
||||||
@@ -282,35 +274,6 @@ func sanitizePath(p string) (string, error) {
|
|||||||
return cleaned, nil
|
return cleaned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkNoSymlinks returns an error if any part of the relative path p
|
|
||||||
// already exists as a symlink. sanitizePath checks p only as text, so
|
|
||||||
// without this a symlink inside the target directory could send a write
|
|
||||||
// to p outside of it. Parts that do not exist yet are fine: fetch creates
|
|
||||||
// them as plain directories and files. Call it immediately before each
|
|
||||||
// write: a symlink created after it returns is not caught.
|
|
||||||
func checkNoSymlinks(p string) error {
|
|
||||||
current := ""
|
|
||||||
|
|
||||||
for _, part := range strings.Split(p, string(filepath.Separator)) {
|
|
||||||
current = filepath.Join(current, part)
|
|
||||||
|
|
||||||
info, err := os.Lstat(current)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check %s for a symlink: %w", current, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return fmt.Errorf("%w: %s", errSymlinkInPath, current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveManifestURL takes a URL and returns the manifest URL.
|
// resolveManifestURL takes a URL and returns the manifest URL.
|
||||||
// If the URL already ends with .mf, it's returned as-is.
|
// If the URL already ends with .mf, it's returned as-is.
|
||||||
// Otherwise, index.mf is appended.
|
// Otherwise, index.mf is appended.
|
||||||
@@ -456,12 +419,7 @@ func downloadFile(
|
|||||||
// Create parent directories if needed
|
// Create parent directories if needed
|
||||||
dir := filepath.Dir(localPath)
|
dir := filepath.Dir(localPath)
|
||||||
if dir != "" && dir != "." {
|
if dir != "" && dir != "." {
|
||||||
err = checkNoSymlinks(dir)
|
err := os.MkdirAll(dir, dirPerms)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.MkdirAll(dir, dirPerms)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
@@ -489,25 +447,15 @@ func downloadFile(
|
|||||||
totalBytes = expectedSize
|
totalBytes = expectedSize
|
||||||
}
|
}
|
||||||
|
|
||||||
err = checkNoSymlinks(tmpPath)
|
// Create temp file.
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove whatever is at tmpPath, such as a leftover from an
|
|
||||||
// interrupted run, rather than write into it: it may be a hard link
|
|
||||||
// to a file outside the target directory, and removing a hard link
|
|
||||||
// removes only this name. If the removal fails, O_EXCL below makes
|
|
||||||
// the create fail.
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
// Create the temp file only if nothing is at tmpPath (O_EXCL).
|
|
||||||
//
|
//
|
||||||
// G304: tmpPath is a relative path that sanitizePath keeps inside the
|
// G304: tmpPath is derived from localPath, which sanitizePath above
|
||||||
// target directory as text, and checkNoSymlinks just found no symlink
|
// constrains lexically to a relative path that does not escape the
|
||||||
// in it.
|
// destination directory. That is a purely lexical guarantee: it does
|
||||||
out, err := os.OpenFile( //nolint:gosec // G304: see comment above
|
// not resolve symlinks, so a pre-existing symlink inside the
|
||||||
tmpPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, filePerms)
|
// destination tree can still redirect this write outside of it
|
||||||
|
// (tracked in issue #86).
|
||||||
|
out, err := os.Create(tmpPath) //nolint:gosec // G304: see comment above
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -571,11 +519,6 @@ func finishDownload(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = checkNoSymlinks(localPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename temp file to final path
|
// Rename temp file to final path
|
||||||
err = os.Rename(tmpPath, localPath)
|
err = os.Rename(tmpPath, localPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -440,86 +440,3 @@ func TestFetchProgress(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, downloaded)
|
assert.Equal(t, content, downloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchRefusesSymlinks runs fetch into a destination directory that
|
|
||||||
// holds a symlink pointing outside it, in each of the three places fetch
|
|
||||||
// writes: a parent directory, the temp file, and the file itself, which
|
|
||||||
// the temp file is renamed onto; and once as a directory inside a plain
|
|
||||||
// directory. The fetch must fail and nothing outside may change.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the process-global working directory
|
|
||||||
func TestFetchRefusesSymlinks(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
entry string // the manifest's only file
|
|
||||||
link string // symlink placed in the destination directory
|
|
||||||
target string // what link points to, relative to the outside directory
|
|
||||||
}{
|
|
||||||
{"parent directory", "sub/deeper/file.txt", "sub", "."},
|
|
||||||
{"directory inside a plain directory", "docs/data/passwd", "docs/data", "."},
|
|
||||||
{"temp file", testFileTxt, ".file.txt.tmp", "new.txt"},
|
|
||||||
{"file", testFileTxt, testFileTxt, "new.txt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
content := []byte("fetched")
|
|
||||||
sourceFs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, sourceFs.MkdirAll(filepath.Dir("/"+tt.entry), 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(sourceFs, "/"+tt.entry, content, 0o644))
|
|
||||||
|
|
||||||
server := httptest.NewServer(fetchTestHandler(
|
|
||||||
scanToManifest(t, sourceFs), map[string][]byte{tt.entry: content}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
outside := t.TempDir()
|
|
||||||
|
|
||||||
chdirTemp(t)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(tt.link), 0o750))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), tt.link))
|
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
|
||||||
assert.Equal(t, 1, runCLI(opts))
|
|
||||||
assert.Contains(t, testStderr(t, opts), "failed to download "+tt.entry+
|
|
||||||
": symlink in path not allowed: "+tt.link)
|
|
||||||
|
|
||||||
written, err := os.ReadDir(outside)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, written, "fetch wrote outside the destination")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFetchReplacesHardLinkAtTempName runs fetch into a destination
|
|
||||||
// directory that holds, at the temp file's name, a hard link to a file
|
|
||||||
// outside it. To fetch that is an ordinary leftover from an interrupted
|
|
||||||
// earlier run: it must replace it and succeed, and the outside file must
|
|
||||||
// not change.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the process-global working directory
|
|
||||||
func TestFetchReplacesHardLinkAtTempName(t *testing.T) {
|
|
||||||
content := []byte("fetched")
|
|
||||||
sourceFs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, afero.WriteFile(sourceFs, "/"+testFileTxt, content, 0o644))
|
|
||||||
|
|
||||||
server := httptest.NewServer(fetchTestHandler(
|
|
||||||
scanToManifest(t, sourceFs), map[string][]byte{testFileTxt: content}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
outsideFile := filepath.Join(t.TempDir(), "secret.txt")
|
|
||||||
require.NoError(t, os.WriteFile(outsideFile, []byte("outside"), 0o600))
|
|
||||||
|
|
||||||
chdirTemp(t)
|
|
||||||
require.NoError(t, os.Link(outsideFile, ".file.txt.tmp"))
|
|
||||||
|
|
||||||
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
|
||||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
||||||
|
|
||||||
fetched, err := os.ReadFile(testFileTxt)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, content, fetched)
|
|
||||||
|
|
||||||
outside, err := os.ReadFile(outsideFile) //nolint:gosec // test-controlled path
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "outside", string(outside), "fetch wrote outside the destination")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -305,7 +304,7 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error {
|
|||||||
// writeFreshenedManifest writes the manifest atomically (write to a
|
// writeFreshenedManifest writes the manifest atomically (write to a
|
||||||
// temp file, then rename over the target).
|
// temp file, then rename over the target).
|
||||||
func writeFreshenedManifest(
|
func writeFreshenedManifest(
|
||||||
ctx context.Context, afs afero.Fs, builder *mfer.Builder, manifestPath string,
|
afs afero.Fs, builder *mfer.Builder, manifestPath string,
|
||||||
) error {
|
) error {
|
||||||
tmpPath := manifestPath + ".tmp"
|
tmpPath := manifestPath + ".tmp"
|
||||||
|
|
||||||
@@ -314,7 +313,7 @@ func writeFreshenedManifest(
|
|||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = builder.Build(ctx, outFile)
|
err = builder.Build(outFile)
|
||||||
_ = outFile.Close()
|
_ = outFile.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -531,7 +530,7 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write updated manifest atomically (write to temp, then rename)
|
// Write updated manifest atomically (write to temp, then rename)
|
||||||
err = writeFreshenedManifest(ctx.Context, mfa.Fs, hasher.builder, manifestPath)
|
err = writeFreshenedManifest(mfa.Fs, hasher.builder, manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const (
|
|||||||
cmdGenerate = "generate"
|
cmdGenerate = "generate"
|
||||||
cmdCheck = "check"
|
cmdCheck = "check"
|
||||||
cmdExport = "export"
|
cmdExport = "export"
|
||||||
cmdFetch = "fetch"
|
|
||||||
|
|
||||||
flagProgress = "progress"
|
flagProgress = "progress"
|
||||||
|
|
||||||
@@ -301,7 +300,7 @@ 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: "fetch",
|
||||||
Usage: "fetch manifest and referenced files",
|
Usage: "fetch manifest and referenced files",
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
mfa.setVerbosity(c)
|
mfa.setVerbosity(c)
|
||||||
@@ -358,6 +357,6 @@ func (mfa *CLIApp) run(args []string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
mfa.exitCode = 1
|
mfa.exitCode = 1
|
||||||
|
|
||||||
log.Errorf("%s", err)
|
log.WithError(err).Debugf("exiting")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-34
@@ -112,16 +112,13 @@ func DisableStyling() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the logger with the CLI handler and default log level.
|
// Init initializes the logger with the CLI handler and default log level.
|
||||||
//
|
|
||||||
// It reconfigures the process-global apex/log logger under the write lock so
|
|
||||||
// the global is never mutated while another goroutine holds the read lock to
|
|
||||||
// read it in emit. Without this, parallel callers (e.g. the test suite) race
|
|
||||||
// Init's SetLevel/SetHandler against concurrent log calls.
|
|
||||||
func Init() {
|
func Init() {
|
||||||
mu.Lock()
|
mu.RLock()
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
log.SetHandler(acli.New(stderr))
|
w := stderr
|
||||||
|
|
||||||
|
mu.RUnlock()
|
||||||
|
log.SetHandler(acli.New(w))
|
||||||
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
|
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,66 +130,74 @@ func isEnabled(l Level) bool {
|
|||||||
return l >= currentLevel
|
return l >= currentLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
// emit calls fn while holding the read lock if messages at level l are
|
|
||||||
// enabled. Holding the read lock across the apex/log call keeps the global
|
|
||||||
// logger from being read while Init reconfigures it under the write lock.
|
|
||||||
func emit(l Level, fn func()) {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if l >= currentLevel {
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fatalf logs a formatted message at fatal level.
|
// Fatalf logs a formatted message at fatal level.
|
||||||
func Fatalf(format string, args ...any) {
|
func Fatalf(format string, args ...any) {
|
||||||
emit(FatalLevel, func() { log.Fatalf(format, args...) })
|
if isEnabled(FatalLevel) {
|
||||||
|
log.Fatalf(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatal logs a message at fatal level.
|
// Fatal logs a message at fatal level.
|
||||||
func Fatal(arg string) {
|
func Fatal(arg string) {
|
||||||
emit(FatalLevel, func() { log.Fatal(arg) })
|
if isEnabled(FatalLevel) {
|
||||||
|
log.Fatal(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs a formatted message at error level.
|
// Errorf logs a formatted message at error level.
|
||||||
func Errorf(format string, args ...any) {
|
func Errorf(format string, args ...any) {
|
||||||
emit(ErrorLevel, func() { log.Errorf(format, args...) })
|
if isEnabled(ErrorLevel) {
|
||||||
|
log.Errorf(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs a message at error level.
|
// Error logs a message at error level.
|
||||||
func Error(arg string) {
|
func Error(arg string) {
|
||||||
emit(ErrorLevel, func() { log.Error(arg) })
|
if isEnabled(ErrorLevel) {
|
||||||
|
log.Error(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warnf logs a formatted message at warn level.
|
// Warnf logs a formatted message at warn level.
|
||||||
func Warnf(format string, args ...any) {
|
func Warnf(format string, args ...any) {
|
||||||
emit(WarnLevel, func() { log.Warnf(format, args...) })
|
if isEnabled(WarnLevel) {
|
||||||
|
log.Warnf(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs a message at warn level.
|
// Warn logs a message at warn level.
|
||||||
func Warn(arg string) {
|
func Warn(arg string) {
|
||||||
emit(WarnLevel, func() { log.Warn(arg) })
|
if isEnabled(WarnLevel) {
|
||||||
|
log.Warn(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs a formatted message at info level.
|
// Infof logs a formatted message at info level.
|
||||||
func Infof(format string, args ...any) {
|
func Infof(format string, args ...any) {
|
||||||
emit(InfoLevel, func() { log.Infof(format, args...) })
|
if isEnabled(InfoLevel) {
|
||||||
|
log.Infof(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs a message at info level.
|
// Info logs a message at info level.
|
||||||
func Info(arg string) {
|
func Info(arg string) {
|
||||||
emit(InfoLevel, func() { log.Info(arg) })
|
if isEnabled(InfoLevel) {
|
||||||
|
log.Info(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbosef logs a formatted message at verbose level.
|
// Verbosef logs a formatted message at verbose level.
|
||||||
func Verbosef(format string, args ...any) {
|
func Verbosef(format string, args ...any) {
|
||||||
emit(VerboseLevel, func() { log.Infof(format, args...) })
|
if isEnabled(VerboseLevel) {
|
||||||
|
log.Infof(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbose logs a message at verbose level.
|
// Verbose logs a message at verbose level.
|
||||||
func Verbose(arg string) {
|
func Verbose(arg string) {
|
||||||
emit(VerboseLevel, func() { log.Info(arg) })
|
if isEnabled(VerboseLevel) {
|
||||||
|
log.Info(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs a formatted message at debug level with caller location.
|
// Debugf logs a formatted message at debug level with caller location.
|
||||||
@@ -211,10 +216,7 @@ func Debug(arg string) {
|
|||||||
|
|
||||||
// DebugReal logs at debug level with caller info from the specified stack depth.
|
// DebugReal logs at debug level with caller info from the specified stack depth.
|
||||||
func DebugReal(arg string, cs int) {
|
func DebugReal(arg string, cs int) {
|
||||||
mu.RLock()
|
if !isEnabled(DebugLevel) {
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if DebugLevel < currentLevel {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +275,11 @@ func GetLevel() Level {
|
|||||||
return currentLevel
|
return currentLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithError returns a log entry with the error attached.
|
||||||
|
func WithError(e error) *log.Entry {
|
||||||
|
return log.Log.WithError(e)
|
||||||
|
}
|
||||||
|
|
||||||
// Progressf prints a progress message that overwrites the current line.
|
// Progressf prints a progress message that overwrites the current line.
|
||||||
// Use ProgressDone() when progress is complete to move to the next line.
|
// Use ProgressDone() when progress is complete to move to the next line.
|
||||||
func Progressf(format string, args ...any) {
|
func Progressf(format string, args ...any) {
|
||||||
|
|||||||
+4
-6
@@ -3,7 +3,6 @@
|
|||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -282,9 +281,8 @@ func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
|||||||
b.signingOptions = opts
|
b.signingOptions = opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build finalizes the manifest and writes it to the writer. ctx bounds the
|
// Build finalizes the manifest and writes it to the writer.
|
||||||
// gpg runs that sign the manifest when signing options are set.
|
func (b *Builder) Build(w io.Writer) error {
|
||||||
func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
@@ -310,13 +308,13 @@ func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate outer wrapper
|
// Generate outer wrapper
|
||||||
err := m.generateOuter(ctx)
|
err := m.generateOuter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build: generate outer: %w", err)
|
return fmt.Errorf("build: generate outer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate final output
|
// Generate final output
|
||||||
err = m.generate(ctx)
|
err = m.generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build: generate: %w", err)
|
return fmt.Errorf("build: generate: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -114,7 +113,7 @@ func TestBuilderBuild(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = b.Build(context.Background(), &buf)
|
err = b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should have magic bytes
|
// Should have magic bytes
|
||||||
@@ -178,7 +177,7 @@ func TestBuilderDeterministicOutput(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err := b.Build(context.Background(), &buf)
|
err := b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
@@ -326,7 +325,7 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -384,7 +383,7 @@ func TestManifestString(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -398,7 +397,7 @@ func TestBuilderBuildEmpty(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err := b.Build(context.Background(), &buf)
|
err := b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should still produce valid manifest with 0 files
|
// Should still produce valid manifest with 0 files
|
||||||
@@ -417,7 +416,7 @@ func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -439,7 +438,7 @@ func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -465,7 +464,7 @@ func TestBuilderDeterministicFileOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
require.NoError(t, b.Build(&buf))
|
||||||
m, err := NewManifestFromReader(&buf)
|
m, err := NewManifestFromReader(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -164,12 +164,12 @@ func (c *Checker) SigningPubKey() []byte {
|
|||||||
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
||||||
// temporary keyring and extracts its fingerprint. This validates the key and
|
// temporary keyring and extracts its fingerprint. This validates the key and
|
||||||
// returns its actual fingerprint from the key material itself.
|
// returns its actual fingerprint from the key material itself.
|
||||||
func (c *Checker) ExtractEmbeddedSigningKeyFP(ctx context.Context) (string, error) {
|
func (c *Checker) ExtractEmbeddedSigningKeyFP() (string, error) {
|
||||||
if len(c.signingPubKey) == 0 {
|
if len(c.signingPubKey) == 0 {
|
||||||
return "", errNoSigningPubKey
|
return "", errNoSigningPubKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return gpgExtractPubKeyFingerprint(ctx, c.signingPubKey)
|
return gpgExtractPubKeyFingerprint(c.signingPubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check verifies all files against the manifest.
|
// Check verifies all files against the manifest.
|
||||||
@@ -312,10 +312,6 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
||||||
// entry.GetPath() is safe to join here: a manifest's entry paths are
|
|
||||||
// validated against the path invariants when it is loaded (see
|
|
||||||
// deserializeInner) or built (see Builder.AddFile), so a traversal or
|
|
||||||
// absolute path can never reach this point.
|
|
||||||
absPath := filepath.Join(string(c.basePath), entry.GetPath())
|
absPath := filepath.Join(string(c.basePath), entry.GetPath())
|
||||||
relPath := RelFilePath(entry.GetPath())
|
relPath := RelFilePath(entry.GetPath())
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func createTestManifest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
require.NoError(t, builder.Build(context.Background(), &buf))
|
require.NoError(t, builder.Build(&buf))
|
||||||
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -26,7 +25,6 @@ var (
|
|||||||
errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size")
|
errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size")
|
||||||
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
||||||
errInvalidFileFormat = errors.New("invalid file format")
|
errInvalidFileFormat = errors.New("invalid file format")
|
||||||
errInvalidManifestPath = errors.New("manifest contains invalid path")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// validateUUID checks that the byte slice is a valid UUID (16 bytes, parseable).
|
// validateUUID checks that the byte slice is a valid UUID (16 bytes, parseable).
|
||||||
@@ -93,9 +91,7 @@ func (m *manifest) verifyOuterIntegrity() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading a manifest takes no context; gpgTimeout still bounds gpg.
|
|
||||||
err = gpgVerify(
|
err = gpgVerify(
|
||||||
context.Background(),
|
|
||||||
[]byte(sigString),
|
[]byte(sigString),
|
||||||
m.pbOuter.GetSignature(),
|
m.pbOuter.GetSignature(),
|
||||||
m.pbOuter.GetSigningPubKey(),
|
m.pbOuter.GetSigningPubKey(),
|
||||||
@@ -185,19 +181,6 @@ func (m *manifest) deserializeInner() error {
|
|||||||
return errUUIDMismatch
|
return errUUIDMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce the manifest path invariants on every entry as it is loaded,
|
|
||||||
// so that no consumer of a manifest — Checker today, any restore or
|
|
||||||
// extract path tomorrow — acts on a traversal or absolute path from an
|
|
||||||
// untrusted .mf. Reject loudly on the first offender rather than
|
|
||||||
// dropping entries, which would let a hostile manifest hide files from a
|
|
||||||
// check.
|
|
||||||
for _, f := range m.pbInner.GetFiles() {
|
|
||||||
err = ValidatePath(f.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles()))
|
log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles()))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests exercise unexported internals
|
|
||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// craftInnerBytes builds the wire bytes of an inner MFFile holding a single
|
|
||||||
// file entry whose path is exactly pathBytes. It writes the wire form by hand
|
|
||||||
// so a hostile path — including one that is not valid UTF-8 — can be embedded
|
|
||||||
// without proto.Marshal's own UTF-8 enforcement rejecting it first.
|
|
||||||
func craftInnerBytes(id uuid.UUID, pathBytes string) []byte {
|
|
||||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
|
||||||
entry = protowire.AppendString(entry, pathBytes)
|
|
||||||
|
|
||||||
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version
|
|
||||||
inner = protowire.AppendVarint(inner, uint64(MFFile_VERSION_ONE))
|
|
||||||
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
|
|
||||||
inner = protowire.AppendBytes(inner, entry)
|
|
||||||
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
|
|
||||||
inner = protowire.AppendBytes(inner, id[:])
|
|
||||||
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapInner wraps inner MFFile wire bytes in a complete, well-formed .mf
|
|
||||||
// envelope (magic prefix, zstd-compressed payload, matching hash and UUID) so
|
|
||||||
// that deserialization reaches path validation rather than failing earlier on
|
|
||||||
// an integrity check.
|
|
||||||
func wrapInner(t *testing.T, id uuid.UUID, innerData []byte) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var cbuf bytes.Buffer
|
|
||||||
|
|
||||||
zw, err := zstd.NewWriter(&cbuf, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = zw.Write(innerData)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, zw.Close())
|
|
||||||
|
|
||||||
compressed := cbuf.Bytes()
|
|
||||||
sum := sha256.Sum256(compressed)
|
|
||||||
|
|
||||||
outer := &MFFileOuter{
|
|
||||||
InnerMessage: compressed,
|
|
||||||
Size: int64(len(innerData)),
|
|
||||||
Sha256: sum[:],
|
|
||||||
Uuid: id[:],
|
|
||||||
Version: MFFileOuter_VERSION_ONE,
|
|
||||||
CompressionType: MFFileOuter_COMPRESSION_ZSTD,
|
|
||||||
}
|
|
||||||
|
|
||||||
ob, err := proto.Marshal(outer)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return append([]byte(MAGIC), ob...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
}{
|
|
||||||
{"parent traversal", "../escape"},
|
|
||||||
{"interior traversal", "a/../../escape"},
|
|
||||||
{"absolute path", "/etc/passwd"},
|
|
||||||
{"backslash path", `a\b`},
|
|
||||||
{"double slash", "a//b"},
|
|
||||||
{"empty path", ""},
|
|
||||||
{"invalid utf-8", "abc\xff"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id := uuid.New()
|
|
||||||
data := wrapInner(t, id, craftInnerBytes(id, tt.path))
|
|
||||||
|
|
||||||
_, err := NewManifestFromReader(bytes.NewReader(data))
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
if tt.path == "abc\xff" {
|
|
||||||
// A path that is not valid UTF-8 cannot survive the proto3
|
|
||||||
// string decoder, which rejects it before path validation
|
|
||||||
// runs; the manifest is still refused at load time.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errInvalidManifestPath)
|
|
||||||
|
|
||||||
if tt.path != "" {
|
|
||||||
// ValidatePath quotes the path with %q; assert against the
|
|
||||||
// same rendering so escaped characters (e.g. a backslash)
|
|
||||||
// still match.
|
|
||||||
assert.Contains(t, err.Error(), fmt.Sprintf("%q", tt.path),
|
|
||||||
"error must name the offending path")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeserializeValidManifestRoundTrips(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hash := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, hash))
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
require.NoError(t, b.Build(context.Background(), &buf))
|
|
||||||
|
|
||||||
m, err := NewManifestFromReader(bytes.NewReader(buf.Bytes()))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files := m.Files()
|
|
||||||
require.Len(t, files, 1)
|
|
||||||
assert.Equal(t, "dir/file.txt", files[0].GetPath())
|
|
||||||
assert.Equal(t, int64(123), files[0].GetSize())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidatePathRejectsInvalidUTF8 pins the ValidatePath rule that a manifest
|
|
||||||
// path must be valid UTF-8, independent of the proto decoder that also enforces
|
|
||||||
// it on the wire.
|
|
||||||
func TestValidatePathRejectsInvalidUTF8(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := ValidatePath("abc\xff")
|
|
||||||
require.ErrorIs(t, err, errPathNotUTF8)
|
|
||||||
assert.Contains(t, err.Error(), "UTF-8")
|
|
||||||
}
|
|
||||||
+2
-4
@@ -2,7 +2,6 @@
|
|||||||
package mfer
|
package mfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -81,7 +80,6 @@ func TestSerializeInternalErrorMessagesVerbatim(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m := &manifest{}
|
m := &manifest{}
|
||||||
require.EqualError(t, m.generate(context.Background()),
|
require.EqualError(t, m.generate(), "internal error: pbInner not set")
|
||||||
"internal error: pbInner not set")
|
require.EqualError(t, m.generateOuter(), "internal error")
|
||||||
require.EqualError(t, m.generateOuter(context.Background()), "internal error")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-38
@@ -10,15 +10,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// gpgTimeout bounds every gpg run, which can otherwise wait forever on
|
|
||||||
// a passphrase prompt or a stalled gpg-agent. A minute leaves a person
|
|
||||||
// time to type a passphrase or touch a smartcard.
|
|
||||||
gpgTimeout = time.Minute
|
|
||||||
|
|
||||||
// privateDirPerms is the permission mode for temporary GPG home
|
// privateDirPerms is the permission mode for temporary GPG home
|
||||||
// directories.
|
// directories.
|
||||||
privateDirPerms os.FileMode = 0o700
|
privateDirPerms os.FileMode = 0o700
|
||||||
@@ -72,17 +66,8 @@ func gpgArgs(opts []string, positional ...string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runGPG runs the gpg binary in batch mode with the given arguments and
|
// runGPG runs the gpg binary in batch mode with the given arguments and
|
||||||
// optional stdin, returning captured stdout and stderr. gpg is killed when
|
// optional stdin, returning captured stdout and stderr.
|
||||||
// ctx ends or gpgTimeout passes, whichever comes first.
|
func runGPG(stdin io.Reader, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||||
func runGPG(
|
|
||||||
ctx context.Context, stdin io.Reader, args ...string,
|
|
||||||
) (*bytes.Buffer, *bytes.Buffer, error) {
|
|
||||||
// exec.CommandContext kills only gpg itself, and that is enough: a
|
|
||||||
// gpg-agent that gpg starts runs detached in its own session and does
|
|
||||||
// not hold gpg's output open, so Run returns as soon as gpg dies.
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, gpgTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
fullArgs := append([]string{"--batch", "--no-tty"}, args...)
|
fullArgs := append([]string{"--batch", "--no-tty"}, args...)
|
||||||
|
|
||||||
// G204: the executable name is a compile-time constant. The arguments
|
// G204: the executable name is a compile-time constant. The arguments
|
||||||
@@ -91,7 +76,7 @@ func runGPG(
|
|||||||
// option or after the "--" end-of-options marker inserted by gpgArgs,
|
// option or after the "--" end-of-options marker inserted by gpgArgs,
|
||||||
// and therefore cannot be reinterpreted by gpg as an option.
|
// and therefore cannot be reinterpreted by gpg as an option.
|
||||||
cmd := exec.CommandContext( //nolint:gosec // G204: see comment above
|
cmd := exec.CommandContext( //nolint:gosec // G204: see comment above
|
||||||
ctx, "gpg", fullArgs...)
|
context.Background(), "gpg", fullArgs...)
|
||||||
cmd.Stdin = stdin
|
cmd.Stdin = stdin
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
@@ -100,14 +85,6 @@ func runGPG(
|
|||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil && ctx.Err() != nil {
|
|
||||||
// gpg was killed because ctx ended, which Run reports only as
|
|
||||||
// "signal: killed"; return the reason instead.
|
|
||||||
err = ctx.Err()
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
err = fmt.Errorf("gpg timed out: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &stdout, &stderr, err
|
return &stdout, &stderr, err
|
||||||
}
|
}
|
||||||
@@ -128,8 +105,8 @@ func parseFingerprint(colonOutput string) (string, bool) {
|
|||||||
|
|
||||||
// gpgSign creates a detached signature of the data using the specified key.
|
// gpgSign creates a detached signature of the data using the specified key.
|
||||||
// Returns the armored detached signature.
|
// Returns the armored detached signature.
|
||||||
func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||||
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
stdout, stderr, err := runGPG(bytes.NewReader(data),
|
||||||
"--detach-sign",
|
"--detach-sign",
|
||||||
gpgOptArmor,
|
gpgOptArmor,
|
||||||
"--local-user", string(keyID),
|
"--local-user", string(keyID),
|
||||||
@@ -143,8 +120,8 @@ func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
|||||||
|
|
||||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||||
// Returns the armored public key.
|
// Returns the armored public key.
|
||||||
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||||
stdout, stderr, err := runGPG(ctx, nil,
|
stdout, stderr, err := runGPG(nil,
|
||||||
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,8 +136,8 @@ func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||||
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||||
stdout, stderr, err := runGPG(ctx, nil,
|
stdout, stderr, err := runGPG(nil,
|
||||||
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,7 +157,7 @@ func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
|||||||
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
||||||
// and extracts its fingerprint. This verifies the key is valid and returns
|
// and extracts its fingerprint. This verifies the key is valid and returns
|
||||||
// the actual fingerprint from the key material.
|
// the actual fingerprint from the key material.
|
||||||
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
|
func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||||
// Create temporary directory for GPG operations
|
// Create temporary directory for GPG operations
|
||||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,7 +181,7 @@ func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import the public key into the temporary keyring
|
// Import the public key into the temporary keyring
|
||||||
_, importStderr, err := runGPG(ctx, nil,
|
_, importStderr, err := runGPG(nil,
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,7 +191,7 @@ func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List keys to get fingerprint
|
// List keys to get fingerprint
|
||||||
listStdout, listStderr, err := runGPG(ctx, nil,
|
listStdout, listStderr, err := runGPG(nil,
|
||||||
"--homedir", tmpDir,
|
"--homedir", tmpDir,
|
||||||
"--with-colons",
|
"--with-colons",
|
||||||
"--fingerprint",
|
"--fingerprint",
|
||||||
@@ -235,7 +212,7 @@ func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, er
|
|||||||
|
|
||||||
// gpgVerify verifies a detached signature against data using the provided public key.
|
// gpgVerify verifies a detached signature against data using the provided public key.
|
||||||
// It creates a temporary keyring to import the public key for verification.
|
// It creates a temporary keyring to import the public key for verification.
|
||||||
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
|
func gpgVerify(data, signature, pubKey []byte) error {
|
||||||
// Create temporary directory for GPG operations
|
// Create temporary directory for GPG operations
|
||||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -275,7 +252,7 @@ func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import the public key into the temporary keyring
|
// Import the public key into the temporary keyring
|
||||||
_, importStderr, err := runGPG(ctx, nil,
|
_, importStderr, err := runGPG(nil,
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -285,7 +262,7 @@ func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
_, verifyStderr, err := runGPG(ctx, nil,
|
_, verifyStderr, err := runGPG(nil,
|
||||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
||||||
sigFile, dataFile)...,
|
sigFile, dataFile)...,
|
||||||
)
|
)
|
||||||
|
|||||||
+20
-58
@@ -4,13 +4,11 @@ package mfer
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -45,11 +43,8 @@ Expire-Date: 0
|
|||||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||||
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
||||||
cmd := exec.CommandContext(ctx, "gpg",
|
cmd := exec.CommandContext(context.Background(), "gpg",
|
||||||
"--batch", "--gen-key", paramsFile)
|
"--batch", "--gen-key", paramsFile)
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
@@ -60,7 +55,7 @@ Expire-Date: 0
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the key fingerprint
|
// Get the key fingerprint
|
||||||
cmd = exec.CommandContext(ctx, "gpg",
|
cmd = exec.CommandContext(context.Background(), "gpg",
|
||||||
"--list-keys", "--with-colons", "test@mfer.test")
|
"--list-keys", "--with-colons", "test@mfer.test")
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
@@ -95,7 +90,7 @@ func TestGPGSign(t *testing.T) {
|
|||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
data := []byte("test data to sign")
|
data := []byte("test data to sign")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, sig)
|
assert.NotEmpty(t, sig)
|
||||||
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
||||||
@@ -106,7 +101,7 @@ func TestGPGExportPublicKey(t *testing.T) {
|
|||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, gpgHome := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, pubKey)
|
assert.NotEmpty(t, pubKey)
|
||||||
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
@@ -117,7 +112,7 @@ func TestGPGGetKeyFingerprint(t *testing.T) {
|
|||||||
keyID, gpgHome := testGPGEnv(t)
|
keyID, gpgHome := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
fingerprint, err := gpgGetKeyFingerprint(context.Background(), keyID)
|
fingerprint, err := gpgGetKeyFingerprint(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, fingerprint)
|
assert.NotEmpty(t, fingerprint)
|
||||||
// The fingerprint should be 40 hex chars
|
// The fingerprint should be 40 hex chars
|
||||||
@@ -151,12 +146,12 @@ func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
|
|||||||
_, gpgHome := testGPGEnv(t)
|
_, gpgHome := testGPGEnv(t)
|
||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), GPGKeyID("--version"))
|
pubKey, err := gpgExportPublicKey(GPGKeyID("--version"))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, errGPGKeyNotFound)
|
require.ErrorIs(t, err, errGPGKeyNotFound)
|
||||||
assert.NotContains(t, string(pubKey), "gpg (GnuPG)")
|
assert.NotContains(t, string(pubKey), "gpg (GnuPG)")
|
||||||
|
|
||||||
fpr, err := gpgGetKeyFingerprint(context.Background(), GPGKeyID("--version"))
|
fpr, err := gpgGetKeyFingerprint(GPGKeyID("--version"))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
||||||
}
|
}
|
||||||
@@ -167,8 +162,7 @@ func TestGPGSignInvalidKey(t *testing.T) {
|
|||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
data := []byte("test data")
|
data := []byte("test data")
|
||||||
_, err := gpgSign(context.Background(), data,
|
_, err := gpgSign(data, GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
||||||
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +185,7 @@ func TestBuilderWithSigning(t *testing.T) {
|
|||||||
// Build the manifest
|
// Build the manifest
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = b.Build(context.Background(), &buf)
|
err = b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse the manifest and verify signature fields are populated
|
// Parse the manifest and verify signature fields are populated
|
||||||
@@ -257,14 +251,14 @@ func TestGPGVerify(t *testing.T) {
|
|||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
data := []byte("test data to sign and verify")
|
data := []byte("test data to sign and verify")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
err = gpgVerify(context.Background(), data, sig, pubKey)
|
err = gpgVerify(data, sig, pubKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,15 +267,15 @@ func TestGPGVerifyInvalidSignature(t *testing.T) {
|
|||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
data := []byte("test data to sign")
|
data := []byte("test data to sign")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Try to verify with different data - should fail
|
// Try to verify with different data - should fail
|
||||||
wrongData := []byte("different data")
|
wrongData := []byte("different data")
|
||||||
err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
err = gpgVerify(wrongData, sig, pubKey)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,12 +284,12 @@ func TestGPGVerifyBadPublicKey(t *testing.T) {
|
|||||||
t.Setenv("GNUPGHOME", gpgHome)
|
t.Setenv("GNUPGHOME", gpgHome)
|
||||||
|
|
||||||
data := []byte("test data")
|
data := []byte("test data")
|
||||||
sig, err := gpgSign(context.Background(), data, keyID)
|
sig, err := gpgSign(data, keyID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Try to verify with invalid public key - should fail
|
// Try to verify with invalid public key - should fail
|
||||||
badPubKey := []byte("not a valid public key")
|
badPubKey := []byte("not a valid public key")
|
||||||
err = gpgVerify(context.Background(), data, sig, badPubKey)
|
err = gpgVerify(data, sig, badPubKey)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +312,7 @@ func TestManifestSignatureVerification(t *testing.T) {
|
|||||||
// Build the manifest
|
// Build the manifest
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = b.Build(context.Background(), &buf)
|
err = b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse the manifest - signature should be verified during load
|
// Parse the manifest - signature should be verified during load
|
||||||
@@ -347,7 +341,7 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = b.Build(context.Background(), &buf)
|
err = b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Tamper with the signature by replacing some bytes
|
// Tamper with the signature by replacing some bytes
|
||||||
@@ -381,7 +375,7 @@ func TestBuilderWithoutSigning(t *testing.T) {
|
|||||||
// Build the manifest
|
// Build the manifest
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = b.Build(context.Background(), &buf)
|
err = b.Build(&buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse the manifest and verify signature fields are empty
|
// Parse the manifest and verify signature fields are empty
|
||||||
@@ -396,35 +390,3 @@ func TestBuilderWithoutSigning(t *testing.T) {
|
|||||||
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
|
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
|
||||||
"signing public key should be empty when not signing")
|
"signing public key should be empty when not signing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGPGTimeoutKillsGPG puts a fake gpg that never finishes first on
|
|
||||||
// PATH and checks that a run past its deadline is killed and reported as
|
|
||||||
// a timeout of the named operation, instead of hanging.
|
|
||||||
func TestGPGTimeoutKillsGPG(t *testing.T) {
|
|
||||||
binDir := t.TempDir()
|
|
||||||
fakeGPG := []byte("#!/bin/sh\nexec sleep 10\n")
|
|
||||||
//nolint:gosec // G306: the fake gpg has to be executable
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, "gpg"), fakeGPG, 0o700))
|
|
||||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
||||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
||||||
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg
|
|
||||||
// runs that sign a manifest through the context given to Build.
|
|
||||||
func TestBuildPassesContextToSigning(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
b.SetSigningOptions(&SigningOptions{KeyID: "any"})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-1
@@ -283,7 +283,8 @@ func (s *Scanner) ToManifest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build and write manifest
|
// Build and write manifest
|
||||||
return builder.Build(ctx, w)
|
//nolint:contextcheck // Build's GPG signing exec is not cancellable by design
|
||||||
|
return builder.Build(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureBuilder constructs a manifest builder configured from the
|
// configureBuilder constructs a manifest builder configured from the
|
||||||
|
|||||||
+8
-9
@@ -2,7 +2,6 @@ package mfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -51,13 +50,13 @@ func newTimestampFromTime(t time.Time) *Timestamp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manifest) generate(ctx context.Context) error {
|
func (m *manifest) generate() error {
|
||||||
if m.pbInner == nil {
|
if m.pbInner == nil {
|
||||||
return errInnerNotSet
|
return errInnerNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.pbOuter == nil {
|
if m.pbOuter == nil {
|
||||||
e := m.generateOuter(ctx)
|
e := m.generateOuter()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -78,7 +77,7 @@ func (m *manifest) generate(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manifest) generateOuter(ctx context.Context) error {
|
func (m *manifest) generateOuter() error {
|
||||||
if m.pbInner == nil {
|
if m.pbInner == nil {
|
||||||
return errInternal
|
return errInternal
|
||||||
}
|
}
|
||||||
@@ -136,7 +135,7 @@ func (m *manifest) generateOuter(ctx context.Context) error {
|
|||||||
|
|
||||||
// Sign the manifest if signing options are provided
|
// Sign the manifest if signing options are provided
|
||||||
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
||||||
return m.signOuter(ctx)
|
return m.signOuter()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -144,27 +143,27 @@ func (m *manifest) generateOuter(ctx context.Context) error {
|
|||||||
|
|
||||||
// signOuter signs the outer message with the configured GPG key and
|
// signOuter signs the outer message with the configured GPG key and
|
||||||
// embeds the signature, signer fingerprint, and public key.
|
// embeds the signature, signer fingerprint, and public key.
|
||||||
func (m *manifest) signOuter(ctx context.Context) error {
|
func (m *manifest) signOuter() error {
|
||||||
sigString, err := m.signatureString()
|
sigString, err := m.signatureString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate signature string: %w", err)
|
return fmt.Errorf("failed to generate signature string: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sig, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
|
sig, err := gpgSign([]byte(sigString), m.signingOptions.KeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to sign manifest: %w", err)
|
return fmt.Errorf("failed to sign manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pbOuter.Signature = sig
|
m.pbOuter.Signature = sig
|
||||||
|
|
||||||
fingerprint, err := gpgGetKeyFingerprint(ctx, m.signingOptions.KeyID)
|
fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pbOuter.Signer = fingerprint
|
m.pbOuter.Signer = fingerprint
|
||||||
|
|
||||||
pubKey, err := gpgExportPublicKey(ctx, m.signingOptions.KeyID)
|
pubKey, err := gpgExportPublicKey(m.signingOptions.KeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to export public key: %w", err)
|
return fmt.Errorf("failed to export public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -1,8 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
# --no-cache because the gate phases the final stage depends on are RUN
|
# Generic: needs no adaptation.
|
||||||
# steps, and a cached one is a check that did not run.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -10,15 +9,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Own line: a failing command substitution inside an argument does
|
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
|
||||||
# the version a build stage derives from the .git in the context.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
docker build --no-cache \
|
|
||||||
--build-arg VERSION="$version" \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+1
-6
@@ -17,12 +17,7 @@ ensure_pb() {
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
ensure_pb
|
ensure_pb
|
||||||
go test -timeout 30s -race -cover ./... ||
|
go test -v --timeout 10s ./...
|
||||||
{
|
|
||||||
echo "--- Rerunning with -v for details ---"
|
|
||||||
go test -timeout 30s -race -v ./...
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user