check / check (push) Failing after 1s
Untrusted .mf files were parsed with no path validation, so an entry like ../../etc/passwd reached filepath.Join against the checker's base path and mfer check could stat and read outside it. ValidatePath ran only when building a manifest. It now runs on every entry as the manifest loads, so every consumer is covered. The whole manifest is rejected on the first bad entry instead of dropping it, which could hide files from a check; the error wraps errInvalidManifestPath and names the path. Disclosure: a path that is not valid UTF-8 is refused earlier, by the protobuf string decoder, so that error does not name the path. Model: opus-4-8 (implementation); fable-5-1 (summary)
395 lines
9.7 KiB
Go
395 lines
9.7 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/multiformats/go-multihash"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
var errNoSigningPubKey = errors.New("manifest has no signing public key")
|
|
|
|
// Result represents the outcome of checking a single file.
|
|
type Result struct {
|
|
Path RelFilePath // Relative path from manifest
|
|
Status Status // Verification result status
|
|
Message string // Human-readable description of the result
|
|
}
|
|
|
|
// Status represents the verification status of a file.
|
|
type Status int
|
|
|
|
// Verification result statuses reported for each checked file.
|
|
const (
|
|
StatusOK Status = iota // File matches manifest (size and hash verified)
|
|
StatusMissing // File not found on disk
|
|
StatusSizeMismatch // File size differs from manifest
|
|
StatusHashMismatch // File hash differs from manifest
|
|
StatusExtra // File exists on disk but not in manifest
|
|
StatusError // Error occurred during verification
|
|
)
|
|
|
|
func (s Status) String() string {
|
|
switch s {
|
|
case StatusOK:
|
|
return "OK"
|
|
case StatusMissing:
|
|
return "MISSING"
|
|
case StatusSizeMismatch:
|
|
return "SIZE_MISMATCH"
|
|
case StatusHashMismatch:
|
|
return "HASH_MISMATCH"
|
|
case StatusExtra:
|
|
return "EXTRA"
|
|
case StatusError:
|
|
return "ERROR"
|
|
default:
|
|
return "UNKNOWN"
|
|
}
|
|
}
|
|
|
|
// CheckStatus contains progress information for the check operation.
|
|
type CheckStatus struct {
|
|
TotalFiles FileCount // Total number of files in manifest
|
|
CheckedFiles FileCount // Number of files checked so far
|
|
TotalBytes FileSize // Total bytes to verify (sum of all file sizes)
|
|
CheckedBytes FileSize // Bytes verified so far
|
|
BytesPerSec float64 // Current throughput rate
|
|
ETA time.Duration // Estimated time to completion
|
|
Failures FileCount // Number of verification failures encountered
|
|
}
|
|
|
|
// Checker verifies files against a manifest.
|
|
type Checker struct {
|
|
basePath AbsFilePath
|
|
files []*MFFilePath
|
|
fs afero.Fs
|
|
// manifestPaths is a set of paths in the manifest for quick lookup
|
|
manifestPaths map[RelFilePath]struct{}
|
|
// manifestRelPath is the relative path of the manifest file from
|
|
// basePath (for exclusion)
|
|
manifestRelPath RelFilePath
|
|
// signature info from the manifest
|
|
signature []byte
|
|
signer []byte
|
|
signingPubKey []byte
|
|
}
|
|
|
|
// NewChecker creates a new Checker for the given manifest, base path, and filesystem.
|
|
// The basePath is the directory relative to which manifest paths are resolved.
|
|
// If fs is nil, the real filesystem (OsFs) is used.
|
|
func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, error) {
|
|
if fs == nil {
|
|
fs = afero.NewOsFs()
|
|
}
|
|
|
|
m, err := NewManifestFromFile(fs, manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
abs, err := filepath.Abs(basePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
files := m.Files()
|
|
|
|
manifestPaths := make(map[RelFilePath]struct{}, len(files))
|
|
for _, f := range files {
|
|
manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
|
|
}
|
|
|
|
// Compute manifest's relative path from basePath for exclusion in FindExtraFiles
|
|
absManifest, err := filepath.Abs(manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
manifestRel, err := filepath.Rel(abs, absManifest)
|
|
if err != nil {
|
|
manifestRel = ""
|
|
}
|
|
|
|
return &Checker{
|
|
basePath: AbsFilePath(abs),
|
|
files: files,
|
|
fs: fs,
|
|
manifestPaths: manifestPaths,
|
|
manifestRelPath: RelFilePath(manifestRel),
|
|
signature: m.pbOuter.GetSignature(),
|
|
signer: m.pbOuter.GetSigner(),
|
|
signingPubKey: m.pbOuter.GetSigningPubKey(),
|
|
}, nil
|
|
}
|
|
|
|
// FileCount returns the number of files in the manifest.
|
|
func (c *Checker) FileCount() FileCount {
|
|
return FileCount(len(c.files))
|
|
}
|
|
|
|
// TotalBytes returns the total size of all files in the manifest.
|
|
func (c *Checker) TotalBytes() FileSize {
|
|
var total FileSize
|
|
for _, f := range c.files {
|
|
total += FileSize(f.GetSize())
|
|
}
|
|
|
|
return total
|
|
}
|
|
|
|
// IsSigned returns true if the manifest has a signature.
|
|
func (c *Checker) IsSigned() bool {
|
|
return len(c.signature) > 0
|
|
}
|
|
|
|
// Signer returns the signer fingerprint if the manifest is signed, nil otherwise.
|
|
func (c *Checker) Signer() []byte {
|
|
return c.signer
|
|
}
|
|
|
|
// SigningPubKey returns the signing public key if the manifest is signed,
|
|
// nil otherwise.
|
|
func (c *Checker) SigningPubKey() []byte {
|
|
return c.signingPubKey
|
|
}
|
|
|
|
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
|
// temporary keyring and extracts its fingerprint. This validates the key and
|
|
// returns its actual fingerprint from the key material itself.
|
|
func (c *Checker) ExtractEmbeddedSigningKeyFP() (string, error) {
|
|
if len(c.signingPubKey) == 0 {
|
|
return "", errNoSigningPubKey
|
|
}
|
|
|
|
return gpgExtractPubKeyFingerprint(c.signingPubKey)
|
|
}
|
|
|
|
// Check verifies all files against the manifest.
|
|
// Results are sent to the results channel as files are checked.
|
|
// Progress updates are sent to the progress channel approximately once per second.
|
|
// Both channels are closed when the method returns.
|
|
func (c *Checker) Check(
|
|
ctx context.Context,
|
|
results chan<- Result,
|
|
progress chan<- CheckStatus,
|
|
) error {
|
|
if results != nil {
|
|
defer close(results)
|
|
}
|
|
|
|
if progress != nil {
|
|
defer close(progress)
|
|
}
|
|
|
|
totalFiles := FileCount(len(c.files))
|
|
totalBytes := c.TotalBytes()
|
|
|
|
var (
|
|
checkedFiles FileCount
|
|
checkedBytes FileSize
|
|
failures FileCount
|
|
)
|
|
|
|
startTime := time.Now()
|
|
lastProgressTime := time.Now()
|
|
|
|
for _, entry := range c.files {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
result := c.checkFile(entry, &checkedBytes)
|
|
if result.Status != StatusOK {
|
|
failures++
|
|
}
|
|
|
|
checkedFiles++
|
|
|
|
if results != nil {
|
|
results <- result
|
|
}
|
|
|
|
// Send progress at most once per second (rate-limited)
|
|
if progress != nil {
|
|
now := time.Now()
|
|
|
|
isLast := checkedFiles == totalFiles
|
|
if isLast || now.Sub(lastProgressTime) >= time.Second {
|
|
bytesPerSec, eta := computeRateETA(
|
|
time.Since(startTime), checkedBytes, totalBytes,
|
|
)
|
|
|
|
sendCheckStatus(progress, CheckStatus{
|
|
TotalFiles: totalFiles,
|
|
CheckedFiles: checkedFiles,
|
|
TotalBytes: totalBytes,
|
|
CheckedBytes: checkedBytes,
|
|
BytesPerSec: bytesPerSec,
|
|
ETA: eta,
|
|
Failures: failures,
|
|
})
|
|
|
|
lastProgressTime = now
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FindExtraFiles walks the filesystem and reports files not in the manifest.
|
|
// Results are sent to the results channel. The channel is closed when done.
|
|
// Hidden files/directories (starting with .) are skipped, as they are excluded
|
|
// from manifests by default. The manifest file itself is also skipped.
|
|
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
|
if results != nil {
|
|
defer close(results)
|
|
}
|
|
|
|
walkFn := func(walkPath string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Get relative path
|
|
rel, err := filepath.Rel(string(c.basePath), walkPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Skip hidden files and directories (dotfiles)
|
|
if IsHiddenPath(filepath.ToSlash(rel)) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Skip directories
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
relPath := RelFilePath(rel)
|
|
|
|
// Skip the manifest file itself
|
|
if relPath == c.manifestRelPath {
|
|
return nil
|
|
}
|
|
|
|
// Check if path is in manifest
|
|
if _, exists := c.manifestPaths[relPath]; !exists {
|
|
if results != nil {
|
|
results <- Result{
|
|
Path: relPath,
|
|
Status: StatusExtra,
|
|
Message: "not in manifest",
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return afero.Walk(c.fs, string(c.basePath), walkFn)
|
|
}
|
|
|
|
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())
|
|
relPath := RelFilePath(entry.GetPath())
|
|
|
|
// Check if file exists
|
|
info, err := c.fs.Stat(absPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) || errors.Is(err, afero.ErrFileNotFound) {
|
|
return Result{
|
|
Path: relPath,
|
|
Status: StatusMissing,
|
|
Message: "file not found",
|
|
}
|
|
}
|
|
|
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
}
|
|
|
|
// Check size
|
|
if info.Size() != entry.GetSize() {
|
|
*checkedBytes += FileSize(info.Size())
|
|
|
|
return Result{
|
|
Path: relPath,
|
|
Status: StatusSizeMismatch,
|
|
Message: "size mismatch",
|
|
}
|
|
}
|
|
|
|
// Open and hash file
|
|
f, err := c.fs.Open(absPath)
|
|
if err != nil {
|
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
h := sha256.New()
|
|
|
|
n, err := io.Copy(h, f)
|
|
if err != nil {
|
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
}
|
|
|
|
*checkedBytes += FileSize(n)
|
|
|
|
// Encode as multihash and compare
|
|
computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
|
if err != nil {
|
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
|
}
|
|
|
|
// Check against all hashes in manifest (at least one must match)
|
|
for _, hash := range entry.GetHashes() {
|
|
if bytes.Equal(computed, hash.GetMultiHash()) {
|
|
return Result{Path: relPath, Status: StatusOK}
|
|
}
|
|
}
|
|
|
|
return Result{
|
|
Path: relPath,
|
|
Status: StatusHashMismatch,
|
|
Message: "hash mismatch",
|
|
}
|
|
}
|
|
|
|
// sendCheckStatus sends a status update without blocking.
|
|
func sendCheckStatus(ch chan<- CheckStatus, status CheckStatus) {
|
|
if ch == nil {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case ch <- status:
|
|
default:
|
|
}
|
|
}
|