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") errManifestPathEmpty = errors.New("manifest path cannot be empty") errBasePathEmpty = errors.New("base path cannot be empty") ) // 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 } // CheckerOptions configures a Checker. type CheckerOptions struct { // ManifestPath is the manifest file to check against (required). ManifestPath string // BasePath is the directory relative to which manifest paths are // resolved (required). BasePath string // Fs is the filesystem to use, defaults to OsFs if nil. Fs afero.Fs } // NewChecker creates a new Checker with the given options. It returns an // error if opts is nil or either path is empty. func NewChecker(opts *CheckerOptions) (*Checker, error) { if opts == nil || opts.ManifestPath == "" { return nil, errManifestPathEmpty } if opts.BasePath == "" { return nil, errBasePathEmpty } fs := opts.Fs if fs == nil { fs = afero.NewOsFs() } m, err := NewManifestFromFile(&ManifestFromFileOptions{ Path: opts.ManifestPath, Fs: fs, }) if err != nil { return nil, err } abs, err := filepath.Abs(opts.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(opts.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(ctx context.Context) (string, error) { if len(c.signingPubKey) == 0 { return "", errNoSigningPubKey } return gpgExtractPubKeyFingerprint(ctx, 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: } }