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
362 lines
9.1 KiB
Go
362 lines
9.1 KiB
Go
// Package mfer implements the mfer manifest file format: building,
|
|
// serializing, verifying, and checking manifests of file trees.
|
|
package mfer
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/multiformats/go-multihash"
|
|
)
|
|
|
|
// readChunkSize is the buffer size used when reading file contents for
|
|
// hashing.
|
|
const readChunkSize = 64 * 1024
|
|
|
|
// The errPath* sentinels below are worded as the trailing fragment of the
|
|
// message ValidatePath renders, because the offending path is quoted
|
|
// before them (`path %q ...`). Wrapping them mid-sentence keeps the
|
|
// rendered text exactly as mfer has always printed it. Match them with
|
|
// errors.Is rather than by reading their messages.
|
|
var (
|
|
errPathEmpty = errors.New("path cannot be empty")
|
|
errPathNotUTF8 = errors.New("is not valid UTF-8")
|
|
errPathBackslash = errors.New("contains backslash; use forward slashes only")
|
|
errPathAbsolute = errors.New("is absolute; must be relative")
|
|
errPathEmptySegment = errors.New("contains empty segment")
|
|
errPathDotDot = errors.New("contains '..' segment")
|
|
errSizeMismatch = errors.New("size mismatch")
|
|
errNegativeSize = errors.New("size cannot be negative")
|
|
errHashNotMultihash = errors.New("hash is not a valid multihash")
|
|
errHashTooShort = errors.New("hash digest is too short")
|
|
errDuplicatePath = errors.New("duplicate path")
|
|
)
|
|
|
|
// ValidatePath checks that a file path conforms to manifest path invariants:
|
|
// - Must be valid UTF-8
|
|
// - Must use forward slashes only (no backslashes)
|
|
// - Must be relative (no leading /)
|
|
// - Must not contain ".." segments
|
|
// - Must not contain empty segments (no "//")
|
|
// - Must not be empty
|
|
func ValidatePath(p string) error {
|
|
if p == "" {
|
|
return errPathEmpty
|
|
}
|
|
|
|
if !utf8.ValidString(p) {
|
|
return fmt.Errorf("path %q %w", p, errPathNotUTF8)
|
|
}
|
|
|
|
if strings.ContainsRune(p, '\\') {
|
|
return fmt.Errorf("path %q %w", p, errPathBackslash)
|
|
}
|
|
|
|
if strings.HasPrefix(p, "/") {
|
|
return fmt.Errorf("path %q %w", p, errPathAbsolute)
|
|
}
|
|
|
|
for seg := range strings.SplitSeq(p, "/") {
|
|
if seg == "" {
|
|
return fmt.Errorf("path %q %w", p, errPathEmptySegment)
|
|
}
|
|
|
|
if seg == ".." {
|
|
return fmt.Errorf("path %q %w", p, errPathDotDot)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RelFilePath represents a relative file path within a manifest.
|
|
type RelFilePath string
|
|
|
|
// AbsFilePath represents an absolute file path on the filesystem.
|
|
type AbsFilePath string
|
|
|
|
// FileSize represents the size of a file in bytes.
|
|
type FileSize int64
|
|
|
|
// FileCount represents a count of files.
|
|
type FileCount int64
|
|
|
|
// ModTime represents a file's modification time.
|
|
type ModTime time.Time
|
|
|
|
// UnixSeconds represents seconds since Unix epoch.
|
|
type UnixSeconds int64
|
|
|
|
// UnixNanos represents the nanosecond component of a timestamp (0-999999999).
|
|
type UnixNanos int32
|
|
|
|
// Timestamp converts ModTime to a protobuf Timestamp.
|
|
func (m ModTime) Timestamp() *Timestamp {
|
|
return newTimestampFromTime(time.Time(m))
|
|
}
|
|
|
|
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
|
type Multihash []byte
|
|
|
|
// FileHashProgress reports progress during file hashing.
|
|
type FileHashProgress struct {
|
|
BytesRead FileSize // Total bytes read so far for the current file
|
|
}
|
|
|
|
// Builder constructs a manifest by adding files one at a time.
|
|
type Builder struct {
|
|
mu sync.Mutex
|
|
files []*MFFilePath
|
|
paths map[string]bool // the path of each entry in files
|
|
createdAt time.Time
|
|
includeTimestamps bool
|
|
signingOptions *SigningOptions
|
|
fixedUUID []byte // if set, use this UUID instead of generating one
|
|
}
|
|
|
|
// NewBuilder creates a new Builder.
|
|
func NewBuilder() *Builder {
|
|
return &Builder{
|
|
files: make([]*MFFilePath, 0),
|
|
paths: make(map[string]bool),
|
|
createdAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// SetSeed derives a deterministic UUID from the given seed string.
|
|
// The seed is hashed once with SHA-256 and the first 16 bytes are used
|
|
// as a fixed UUID for the manifest.
|
|
func (b *Builder) SetSeed(seed string) {
|
|
hash := sha256.Sum256([]byte(seed))
|
|
b.fixedUUID = hash[:uuidLength]
|
|
}
|
|
|
|
// AddFile reads file content from reader, computes hashes, and adds to manifest.
|
|
// A path already added is refused once the file is read.
|
|
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
|
// Progress updates are sent to the progress channel (if non-nil) without blocking.
|
|
// Returns the number of bytes read.
|
|
func (b *Builder) AddFile(
|
|
path RelFilePath,
|
|
size FileSize,
|
|
mtime ModTime,
|
|
mode fs.FileMode,
|
|
reader io.Reader,
|
|
progress chan<- FileHashProgress,
|
|
) (FileSize, error) {
|
|
err := ValidatePath(string(path))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Create hash writer
|
|
h := sha256.New()
|
|
|
|
// Read file in chunks, updating hash and progress
|
|
var totalRead FileSize
|
|
|
|
buf := make([]byte, readChunkSize)
|
|
|
|
for {
|
|
n, err := reader.Read(buf)
|
|
if n > 0 {
|
|
h.Write(buf[:n])
|
|
totalRead += FileSize(n)
|
|
sendFileHashProgress(progress, FileHashProgress{BytesRead: totalRead})
|
|
}
|
|
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
return totalRead, err
|
|
}
|
|
}
|
|
|
|
// Verify actual bytes read matches declared size
|
|
if totalRead != size {
|
|
return totalRead, fmt.Errorf(
|
|
"%w for %q: declared %d bytes but read %d bytes",
|
|
errSizeMismatch, path, size, totalRead,
|
|
)
|
|
}
|
|
|
|
// Encode hash as multihash (SHA2-256)
|
|
mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
|
if err != nil {
|
|
return totalRead, err
|
|
}
|
|
|
|
// Create file entry
|
|
entry := &MFFilePath{
|
|
Path: string(path),
|
|
Size: int64(size),
|
|
Hashes: []*MFFileChecksum{
|
|
{MultiHash: mh},
|
|
},
|
|
Mtime: mtime.Timestamp(),
|
|
Mode: uint32(mode.Perm()),
|
|
}
|
|
|
|
return totalRead, b.addEntry(entry)
|
|
}
|
|
|
|
// sendFileHashProgress sends a progress update without blocking.
|
|
func sendFileHashProgress(ch chan<- FileHashProgress, p FileHashProgress) {
|
|
if ch == nil {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case ch <- p:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// FileCount returns the number of files added to the builder.
|
|
func (b *Builder) FileCount() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
return len(b.files)
|
|
}
|
|
|
|
// AddFileWithHash adds a file entry with a pre-computed hash.
|
|
// This is useful when the hash is already known (e.g., from an existing manifest).
|
|
// 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,
|
|
// or hash is not a multihash with a digest of at least 32 bytes, as long
|
|
// as SHA-256's.
|
|
func (b *Builder) AddFileWithHash(
|
|
path RelFilePath,
|
|
size FileSize,
|
|
mtime ModTime,
|
|
mode fs.FileMode,
|
|
hash Multihash,
|
|
) error {
|
|
err := ValidatePath(string(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if size < 0 {
|
|
return errNegativeSize
|
|
}
|
|
|
|
decoded, err := multihash.Decode(hash)
|
|
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{
|
|
Path: string(path),
|
|
Size: int64(size),
|
|
Hashes: []*MFFileChecksum{
|
|
{MultiHash: hash},
|
|
},
|
|
Mtime: mtime.Timestamp(),
|
|
Mode: uint32(mode.Perm()),
|
|
}
|
|
|
|
return b.addEntry(entry)
|
|
}
|
|
|
|
// SetIncludeTimestamps controls whether the manifest includes a createdAt timestamp.
|
|
// By default timestamps are omitted for deterministic output.
|
|
func (b *Builder) SetIncludeTimestamps(include bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
b.includeTimestamps = include
|
|
}
|
|
|
|
// SetSigningOptions sets the GPG signing options for the manifest.
|
|
// If opts is non-nil, the manifest will be signed when Build() is called.
|
|
func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
b.signingOptions = opts
|
|
}
|
|
|
|
// Build finalizes the manifest and writes it to the writer. ctx bounds the
|
|
// gpg runs that sign the manifest when signing options are set.
|
|
func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
// Sort files by path for deterministic output
|
|
sort.Slice(b.files, func(i, j int) bool {
|
|
return b.files[i].GetPath() < b.files[j].GetPath()
|
|
})
|
|
|
|
// Create inner manifest
|
|
inner := &MFFile{
|
|
Version: MFFile_VERSION_ONE,
|
|
Files: b.files,
|
|
}
|
|
if b.includeTimestamps {
|
|
inner.CreatedAt = newTimestampFromTime(b.createdAt)
|
|
}
|
|
|
|
// Create a temporary manifest to use existing serialization
|
|
m := &manifest{
|
|
pbInner: inner,
|
|
signingOptions: b.signingOptions,
|
|
fixedUUID: b.fixedUUID,
|
|
}
|
|
|
|
// Generate outer wrapper
|
|
err := m.generateOuter(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Generate final output
|
|
err = m.generate(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write to output
|
|
_, err = w.Write(m.output.Bytes())
|
|
|
|
return err
|
|
}
|
|
|
|
// addEntry adds entry to the manifest unless an entry with its path is
|
|
// already there.
|
|
func (b *Builder) addEntry(entry *MFFilePath) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.paths[entry.GetPath()] {
|
|
return fmt.Errorf("%w %q", errDuplicatePath, entry.GetPath())
|
|
}
|
|
|
|
b.paths[entry.GetPath()] = true
|
|
b.files = append(b.files, entry)
|
|
|
|
return nil
|
|
}
|