check / check (push) Waiting to run
Error messages in mfer/ and internal/cli/ are lowercase except names and acronyms, carry no "failed to" or command-name prefix, and each wrap names only the operation and thing the wrapped error does not already name, so a stacked message names what failed once. Wraps around errors that already name their operation and path (os and afero path errors, url.Error, the builder's path errors, the gpg helpers' own errors) are dropped. gpg's stderr is appended to a gpg failure, and to the error for a signing key gpg did not report, only when gpg wrote some. errHTTPStatus reads "unexpected HTTP status"; both inner-not-set sentinels read "inner message not set". No sentinel, errors.Is result or exit status changes. Model: opus-5-5
178 lines
4.2 KiB
Go
178 lines
4.2 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
"uuid"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// MAGIC is the file format magic bytes prefix (rot13 of "MANIFEST").
|
|
const MAGIC string = "ZNAVSRFG"
|
|
|
|
var (
|
|
// errInnerNotSet is returned by generate when the inner manifest is
|
|
// missing.
|
|
errInnerNotSet = errors.New("inner message not set")
|
|
// errInternal is returned by generateOuter for the same condition.
|
|
errInternal = errors.New("inner message not set")
|
|
)
|
|
|
|
// nanosecondsInt32 converts t's nanosecond component to int32.
|
|
// time.Time.Nanosecond is documented to return a value in [0, 999999999],
|
|
// so the conversion cannot overflow. This sits directly in the manifest
|
|
// content path: silently substituting a default would zero every entry's
|
|
// mtime nanos and change the serialized bytes and their hash, so an
|
|
// out-of-contract value is a programming error and panics rather than
|
|
// being papered over.
|
|
func nanosecondsInt32(t time.Time) int32 {
|
|
n := t.Nanosecond()
|
|
if n < 0 || n > math.MaxInt32 {
|
|
panic(fmt.Sprintf(
|
|
"mfer: time.Time.Nanosecond out of contract: %d", n))
|
|
}
|
|
|
|
return int32(n)
|
|
}
|
|
|
|
func newTimestampFromTime(t time.Time) *Timestamp {
|
|
return &Timestamp{
|
|
Seconds: t.Unix(),
|
|
Nanos: nanosecondsInt32(t),
|
|
}
|
|
}
|
|
|
|
func (m *manifest) generate(ctx context.Context) error {
|
|
if m.pbInner == nil {
|
|
return errInnerNotSet
|
|
}
|
|
|
|
if m.pbOuter == nil {
|
|
e := m.generateOuter(ctx)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
|
|
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal outer message: %w", err)
|
|
}
|
|
|
|
m.output = bytes.NewBufferString(MAGIC)
|
|
|
|
_, err = m.output.Write(dat)
|
|
if err != nil {
|
|
return fmt.Errorf("write outer message: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *manifest) generateOuter(ctx context.Context) error {
|
|
if m.pbInner == nil {
|
|
return errInternal
|
|
}
|
|
|
|
// Use fixed UUID if provided, otherwise generate a new one
|
|
var manifestUUID uuid.UUID
|
|
if len(m.fixedUUID) == uuidLength {
|
|
copy(manifestUUID[:], m.fixedUUID)
|
|
} else {
|
|
manifestUUID = uuid.NewV4()
|
|
}
|
|
|
|
m.pbInner.Uuid = manifestUUID[:]
|
|
|
|
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal inner message: %w", err)
|
|
}
|
|
|
|
// Compress the inner data
|
|
idc := new(bytes.Buffer)
|
|
|
|
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
|
if err != nil {
|
|
return fmt.Errorf("create compressor: %w", err)
|
|
}
|
|
|
|
_, err = zw.Write(innerData)
|
|
if err != nil {
|
|
return fmt.Errorf("compress inner message: %w", err)
|
|
}
|
|
|
|
_ = zw.Close()
|
|
|
|
compressedData := idc.Bytes()
|
|
|
|
// Hash the compressed data for integrity verification before decompression
|
|
h := sha256.New()
|
|
|
|
_, err = h.Write(compressedData)
|
|
if err != nil {
|
|
return fmt.Errorf("hash inner message: %w", err)
|
|
}
|
|
|
|
sha256Hash := h.Sum(nil)
|
|
|
|
m.pbOuter = &MFFileOuter{
|
|
InnerMessage: compressedData,
|
|
Size: int64(len(innerData)),
|
|
Sha256: sha256Hash,
|
|
Uuid: manifestUUID[:],
|
|
Version: MFFileOuter_VERSION_ONE,
|
|
CompressionType: MFFileOuter_COMPRESSION_ZSTD,
|
|
}
|
|
|
|
// Sign the manifest if signing options are provided
|
|
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
|
return m.signOuter(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// signOuter signs the outer message with the configured GPG key and
|
|
// embeds the signature, signer fingerprint, and public key. The signer
|
|
// and public key are those of the key gpg reports it signed with, so that
|
|
// a key ID matching more than one key cannot name or embed another key.
|
|
func (m *manifest) signOuter(ctx context.Context) error {
|
|
sigString, err := m.signatureString()
|
|
if err != nil {
|
|
return fmt.Errorf("build signature string: %w", err)
|
|
}
|
|
|
|
sig, signingKey, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.pbOuter.Signature = sig
|
|
|
|
// Listing the signing key, a subkey's included, puts its primary key's
|
|
// fingerprint first.
|
|
fingerprint, err := gpgGetKeyFingerprint(ctx, GPGKeyID(signingKey))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.pbOuter.Signer = fingerprint
|
|
|
|
pubKey, err := gpgExportPublicKey(ctx, GPGKeyID(fingerprint))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.pbOuter.SigningPubKey = pubKey
|
|
|
|
return nil
|
|
}
|