From ebaf2a65caad5e8005f911252733d3ac32d6caef Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 01:35:07 +0100 Subject: [PATCH 1/4] Fix AddFile to verify actual bytes read matches declared size (closes #25) (#30) After reading file content, verify `totalRead == size` and return an error on mismatch. Co-authored-by: clawbot Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/30 Co-authored-by: clawbot Co-committed-by: clawbot --- mfer/builder.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mfer/builder.go b/mfer/builder.go index 22d4d4a..db605e2 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -3,6 +3,7 @@ package mfer import ( "crypto/sha256" "errors" + "fmt" "io" "sync" "time" @@ -96,6 +97,11 @@ func (b *Builder) AddFile( } } + // Verify actual bytes read matches declared size + if totalRead != size { + return totalRead, fmt.Errorf("size mismatch for %q: declared %d bytes but read %d bytes", path, size, totalRead) + } + // Encode hash as multihash (SHA2-256) mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256) if err != nil { From 2efffd9da86b7fd34ca5d0ab9dfde79365e8997a Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 01:45:29 +0100 Subject: [PATCH 2/4] Specify and enforce path invariants (closes #26) (#31) Add `ValidatePath()` enforcing UTF-8, forward-slash, relative, no `..`, no empty segments. Applied in `AddFile` and `AddFileWithHash`. Proto comments document the rules. Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/31 Co-authored-by: clawbot Co-committed-by: clawbot --- mfer/builder.go | 41 +++++++++++++++++++++++++++++++++++++++-- mfer/mf.proto | 3 +++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/mfer/builder.go b/mfer/builder.go index db605e2..7864897 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -5,12 +5,45 @@ import ( "errors" "fmt" "io" + "strings" "sync" "time" + "unicode/utf8" "github.com/multiformats/go-multihash" ) +// 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 errors.New("path cannot be empty") + } + if !utf8.ValidString(p) { + return fmt.Errorf("path %q is not valid UTF-8", p) + } + if strings.ContainsRune(p, '\\') { + return fmt.Errorf("path %q contains backslash; use forward slashes only", p) + } + if strings.HasPrefix(p, "/") { + return fmt.Errorf("path %q is absolute; must be relative", p) + } + for _, seg := range strings.Split(p, "/") { + if seg == "" { + return fmt.Errorf("path %q contains empty segment", p) + } + if seg == ".." { + return fmt.Errorf("path %q contains '..' segment", p) + } + } + return nil +} + // RelFilePath represents a relative file path within a manifest. type RelFilePath string @@ -75,6 +108,10 @@ func (b *Builder) AddFile( reader io.Reader, progress chan<- FileHashProgress, ) (FileSize, error) { + if err := ValidatePath(string(path)); err != nil { + return 0, err + } + // Create hash writer h := sha256.New() @@ -147,8 +184,8 @@ func (b *Builder) FileCount() int { // This is useful when the hash is already known (e.g., from an existing manifest). // Returns an error if path is empty, size is negative, or hash is nil/empty. func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime, hash Multihash) error { - if path == "" { - return errors.New("path cannot be empty") + if err := ValidatePath(string(path)); err != nil { + return err } if size < 0 { return errors.New("size cannot be negative") diff --git a/mfer/mf.proto b/mfer/mf.proto index d8a5bac..66748e0 100644 --- a/mfer/mf.proto +++ b/mfer/mf.proto @@ -46,6 +46,9 @@ message MFFileOuter { message MFFilePath { // required attributes: + // Path invariants: must be valid UTF-8, use forward slashes only, + // be relative (no leading /), contain no ".." segments, and no + // empty segments (no "//"). string path = 1; int64 size = 2; From 7144617d0ed1e39c198b9f18526e603d1cae0a8e Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 01:45:55 +0100 Subject: [PATCH 3/4] Add decompression size limit in deserializeInner() (closes #24) (#29) Wrap zstd decompressor with `io.LimitReader` (256MB max) to prevent decompression bombs. Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/29 Co-authored-by: clawbot Co-committed-by: clawbot --- mfer/constants.go | 5 +++++ mfer/deserialize.go | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/mfer/constants.go b/mfer/constants.go index f38c74a..4640637 100644 --- a/mfer/constants.go +++ b/mfer/constants.go @@ -3,4 +3,9 @@ package mfer const ( Version = "0.1.0" ReleaseDate = "2025-12-17" + + // MaxDecompressedSize is the maximum allowed size of decompressed manifest + // data (256 MB). This prevents decompression bombs from consuming excessive + // memory. + MaxDecompressedSize int64 = 256 * 1024 * 1024 ) diff --git a/mfer/deserialize.go b/mfer/deserialize.go index 76a8655..878bf8f 100644 --- a/mfer/deserialize.go +++ b/mfer/deserialize.go @@ -76,10 +76,20 @@ func (m *manifest) deserializeInner() error { } defer zr.Close() - dat, err := io.ReadAll(zr) + // Limit decompressed size to prevent decompression bombs. + // Use declared size + 1 byte to detect overflow, capped at MaxDecompressedSize. + maxSize := MaxDecompressedSize + if m.pbOuter.Size > 0 && m.pbOuter.Size < int64(maxSize) { + maxSize = int64(m.pbOuter.Size) + 1 + } + limitedReader := io.LimitReader(zr, maxSize) + dat, err := io.ReadAll(limitedReader) if err != nil { return err } + if int64(len(dat)) >= MaxDecompressedSize { + return fmt.Errorf("decompressed data exceeds maximum allowed size of %d bytes", MaxDecompressedSize) + } isize := len(dat) if int64(isize) != m.pbOuter.Size { From 04b05e01e834378665d748fc55531208f96ebb87 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 02:09:01 +0100 Subject: [PATCH 4/4] =?UTF-8?q?Consolidate=20scanner/checker=20=E2=80=94?= =?UTF-8?q?=20delete=20internal/scanner/=20and=20internal/checker/=20(clos?= =?UTF-8?q?es=20#22)=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused `internal/scanner/` and `internal/checker/` packages. The CLI already uses `mfer.Scanner` and `mfer.Checker` from the `mfer/` package directly, so these were dead code. Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/27 Co-authored-by: clawbot Co-committed-by: clawbot --- internal/checker/checker.go | 281 --------------------------- internal/scanner/scanner.go | 373 ------------------------------------ 2 files changed, 654 deletions(-) delete mode 100644 internal/checker/checker.go delete mode 100644 internal/scanner/scanner.go diff --git a/internal/checker/checker.go b/internal/checker/checker.go deleted file mode 100644 index 3790c14..0000000 --- a/internal/checker/checker.go +++ /dev/null @@ -1,281 +0,0 @@ -package checker - -import ( - "bytes" - "context" - "crypto/sha256" - "errors" - "io" - "os" - "path/filepath" - - "github.com/multiformats/go-multihash" - "github.com/spf13/afero" - "sneak.berlin/go/mfer/mfer" -) - -// Result represents the outcome of checking a single file. -type Result struct { - Path string // 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 - -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 int64 // Total number of files in manifest - CheckedFiles int64 // Number of files checked so far - TotalBytes int64 // Total bytes to verify (sum of all file sizes) - CheckedBytes int64 // Bytes verified so far - BytesPerSec float64 // Current throughput rate - Failures int64 // Number of verification failures encountered -} - -// Checker verifies files against a manifest. -type Checker struct { - basePath string - files []*mfer.MFFilePath - fs afero.Fs - // manifestPaths is a set of paths in the manifest for quick lookup - manifestPaths map[string]struct{} -} - -// 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 := mfer.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[string]struct{}, len(files)) - for _, f := range files { - manifestPaths[f.Path] = struct{}{} - } - - return &Checker{ - basePath: abs, - files: files, - fs: fs, - manifestPaths: manifestPaths, - }, nil -} - -// FileCount returns the number of files in the manifest. -func (c *Checker) FileCount() int64 { - return int64(len(c.files)) -} - -// TotalBytes returns the total size of all files in the manifest. -func (c *Checker) TotalBytes() int64 { - var total int64 - for _, f := range c.files { - total += f.Size - } - return total -} - -// 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 := int64(len(c.files)) - totalBytes := c.TotalBytes() - - var checkedFiles int64 - var checkedBytes int64 - var failures int64 - - 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 (simplified - every file for now) - if progress != nil { - sendCheckStatus(progress, CheckStatus{ - TotalFiles: totalFiles, - CheckedFiles: checkedFiles, - TotalBytes: totalBytes, - CheckedBytes: checkedBytes, - Failures: failures, - }) - } - } - - return nil -} - -func (c *Checker) checkFile(entry *mfer.MFFilePath, checkedBytes *int64) Result { - absPath := filepath.Join(c.basePath, entry.Path) - - // Check if file exists - info, err := c.fs.Stat(absPath) - if err != nil { - if errors.Is(err, afero.ErrFileNotFound) || errors.Is(err, errors.New("file does not exist")) { - return Result{Path: entry.Path, Status: StatusMissing, Message: "file not found"} - } - // Check for "file does not exist" style errors - exists, _ := afero.Exists(c.fs, absPath) - if !exists { - return Result{Path: entry.Path, Status: StatusMissing, Message: "file not found"} - } - return Result{Path: entry.Path, Status: StatusError, Message: err.Error()} - } - - // Check size - if info.Size() != entry.Size { - *checkedBytes += info.Size() - return Result{ - Path: entry.Path, - Status: StatusSizeMismatch, - Message: "size mismatch", - } - } - - // Open and hash file - f, err := c.fs.Open(absPath) - if err != nil { - return Result{Path: entry.Path, Status: StatusError, Message: err.Error()} - } - defer f.Close() - - h := sha256.New() - n, err := io.Copy(h, f) - if err != nil { - return Result{Path: entry.Path, Status: StatusError, Message: err.Error()} - } - *checkedBytes += n - - // Encode as multihash and compare - computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256) - if err != nil { - return Result{Path: entry.Path, Status: StatusError, Message: err.Error()} - } - - // Check against all hashes in manifest (at least one must match) - for _, hash := range entry.Hashes { - if bytes.Equal(computed, hash.MultiHash) { - return Result{Path: entry.Path, Status: StatusOK} - } - } - - return Result{Path: entry.Path, Status: StatusHashMismatch, Message: "hash mismatch"} -} - -// FindExtraFiles walks the filesystem and reports files not in the manifest. -// Results are sent to the results channel. The channel is closed when done. -func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error { - if results != nil { - defer close(results) - } - - return afero.Walk(c.fs, c.basePath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Skip directories - if info.IsDir() { - return nil - } - - // Get relative path - relPath, err := filepath.Rel(c.basePath, path) - if err != nil { - return err - } - - // 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 - }) -} - -// sendCheckStatus sends a status update without blocking. -func sendCheckStatus(ch chan<- CheckStatus, status CheckStatus) { - if ch == nil { - return - } - select { - case ch <- status: - default: - } -} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go deleted file mode 100644 index 252e16a..0000000 --- a/internal/scanner/scanner.go +++ /dev/null @@ -1,373 +0,0 @@ -package scanner - -import ( - "context" - "io" - "io/fs" - "path" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/spf13/afero" - "sneak.berlin/go/mfer/mfer" -) - -// Phase 1: Enumeration -// --------------------- -// Walking directories and calling stat() on files to collect metadata. -// Builds the list of files to be scanned. Relatively fast (metadata only). - -// EnumerateStatus contains progress information for the enumeration phase. -type EnumerateStatus struct { - FilesFound int64 // Number of files discovered so far - BytesFound int64 // Total size of discovered files (from stat) -} - -// Phase 2: Scan (ToManifest) -// -------------------------- -// Reading file contents and computing hashes for manifest generation. -// This is the expensive phase that reads all file data. - -// ScanStatus contains progress information for the scan phase. -type ScanStatus struct { - TotalFiles int64 // Total number of files to scan - ScannedFiles int64 // Number of files scanned so far - TotalBytes int64 // Total bytes to read (sum of all file sizes) - ScannedBytes int64 // Bytes read so far - BytesPerSec float64 // Current throughput rate -} - -// Options configures scanner behavior. -type Options struct { - IgnoreDotfiles bool // Skip files and directories starting with a dot - FollowSymLinks bool // Resolve symlinks instead of skipping them - Fs afero.Fs // Filesystem to use, defaults to OsFs if nil -} - -// FileEntry represents a file that has been enumerated. -type FileEntry struct { - Path string // Relative path (used in manifest) - AbsPath string // Absolute path (used for reading file content) - Size int64 // File size in bytes - Mtime time.Time // Last modification time - Ctime time.Time // Creation time (platform-dependent) -} - -// Scanner accumulates files and generates manifests from them. -type Scanner struct { - mu sync.RWMutex - files []*FileEntry - options *Options - fs afero.Fs -} - -// New creates a new Scanner with default options. -func New() *Scanner { - return NewWithOptions(nil) -} - -// NewWithOptions creates a new Scanner with the given options. -func NewWithOptions(opts *Options) *Scanner { - if opts == nil { - opts = &Options{} - } - fs := opts.Fs - if fs == nil { - fs = afero.NewOsFs() - } - return &Scanner{ - files: make([]*FileEntry, 0), - options: opts, - fs: fs, - } -} - -// EnumerateFile adds a single file to the scanner, calling stat() to get metadata. -func (s *Scanner) EnumerateFile(filePath string) error { - abs, err := filepath.Abs(filePath) - if err != nil { - return err - } - info, err := s.fs.Stat(abs) - if err != nil { - return err - } - // For single files, use the filename as the relative path - basePath := filepath.Dir(abs) - return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil) -} - -// EnumeratePath walks a directory path and adds all files to the scanner. -// If progress is non-nil, status updates are sent as files are discovered. -// The progress channel is closed when the method returns. -func (s *Scanner) EnumeratePath(inputPath string, progress chan<- EnumerateStatus) error { - if progress != nil { - defer close(progress) - } - abs, err := filepath.Abs(inputPath) - if err != nil { - return err - } - afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs)) - return s.enumerateFS(afs, abs, progress) -} - -// EnumeratePaths walks multiple directory paths and adds all files to the scanner. -// If progress is non-nil, status updates are sent as files are discovered. -// The progress channel is closed when the method returns. -func (s *Scanner) EnumeratePaths(progress chan<- EnumerateStatus, inputPaths ...string) error { - if progress != nil { - defer close(progress) - } - for _, p := range inputPaths { - abs, err := filepath.Abs(p) - if err != nil { - return err - } - afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs)) - if err := s.enumerateFS(afs, abs, progress); err != nil { - return err - } - } - return nil -} - -// EnumerateFS walks an afero filesystem and adds all files to the scanner. -// If progress is non-nil, status updates are sent as files are discovered. -// The progress channel is closed when the method returns. -// basePath is used to compute absolute paths for file reading. -func (s *Scanner) EnumerateFS(afs afero.Fs, basePath string, progress chan<- EnumerateStatus) error { - if progress != nil { - defer close(progress) - } - return s.enumerateFS(afs, basePath, progress) -} - -// enumerateFS is the internal implementation that doesn't close the progress channel. -func (s *Scanner) enumerateFS(afs afero.Fs, basePath string, progress chan<- EnumerateStatus) error { - return afero.Walk(afs, "/", func(p string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if s.options.IgnoreDotfiles && pathIsHidden(p) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - return s.enumerateFileWithInfo(p, basePath, info, progress) - }) -} - -// enumerateFileWithInfo adds a file with pre-existing fs.FileInfo. -func (s *Scanner) enumerateFileWithInfo(filePath string, basePath string, info fs.FileInfo, progress chan<- EnumerateStatus) error { - if info.IsDir() { - // Manifests contain only files, directories are implied - return nil - } - - // Clean the path - remove leading slash if present - cleanPath := filePath - if len(cleanPath) > 0 && cleanPath[0] == '/' { - cleanPath = cleanPath[1:] - } - - // Compute absolute path for file reading - absPath := filepath.Join(basePath, cleanPath) - - entry := &FileEntry{ - Path: cleanPath, - AbsPath: absPath, - Size: info.Size(), - Mtime: info.ModTime(), - // Note: Ctime not available from fs.FileInfo on all platforms - // Will need platform-specific code to extract it - } - - s.mu.Lock() - s.files = append(s.files, entry) - filesFound := int64(len(s.files)) - var bytesFound int64 - for _, f := range s.files { - bytesFound += f.Size - } - s.mu.Unlock() - - sendEnumerateStatus(progress, EnumerateStatus{ - FilesFound: filesFound, - BytesFound: bytesFound, - }) - - return nil -} - -// Files returns a copy of all files added to the scanner. -func (s *Scanner) Files() []*FileEntry { - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]*FileEntry, len(s.files)) - copy(out, s.files) - return out -} - -// FileCount returns the number of files in the scanner. -func (s *Scanner) FileCount() int64 { - s.mu.RLock() - defer s.mu.RUnlock() - return int64(len(s.files)) -} - -// TotalBytes returns the total size of all files in the scanner. -func (s *Scanner) TotalBytes() int64 { - s.mu.RLock() - defer s.mu.RUnlock() - var total int64 - for _, f := range s.files { - total += f.Size - } - return total -} - -// ToManifest reads all file contents, computes hashes, and generates a manifest. -// If progress is non-nil, status updates are sent approximately once per second. -// The progress channel is closed when the method returns. -// The manifest is written to the provided io.Writer. -func (s *Scanner) ToManifest(ctx context.Context, w io.Writer, progress chan<- ScanStatus) error { - if progress != nil { - defer close(progress) - } - - s.mu.RLock() - files := make([]*FileEntry, len(s.files)) - copy(files, s.files) - totalFiles := int64(len(files)) - var totalBytes int64 - for _, f := range files { - totalBytes += f.Size - } - s.mu.RUnlock() - - builder := mfer.NewBuilder() - - var scannedFiles int64 - var scannedBytes int64 - lastProgressTime := time.Now() - startTime := time.Now() - - for _, entry := range files { - // Check for cancellation - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Open file - f, err := s.fs.Open(entry.AbsPath) - if err != nil { - return err - } - - // Add to manifest with progress callback - bytesRead, err := builder.AddFile( - entry.Path, - entry.Size, - entry.Mtime, - f, - func(fileBytes int64) { - // Send progress at most once per second - now := time.Now() - if progress != nil && now.Sub(lastProgressTime) >= time.Second { - elapsed := now.Sub(startTime).Seconds() - currentBytes := scannedBytes + fileBytes - var rate float64 - if elapsed > 0 { - rate = float64(currentBytes) / elapsed - } - sendScanStatus(progress, ScanStatus{ - TotalFiles: totalFiles, - ScannedFiles: scannedFiles, - TotalBytes: totalBytes, - ScannedBytes: currentBytes, - BytesPerSec: rate, - }) - lastProgressTime = now - } - }, - ) - f.Close() - - if err != nil { - return err - } - - scannedFiles++ - scannedBytes += bytesRead - } - - // Send final progress - if progress != nil { - elapsed := time.Since(startTime).Seconds() - var rate float64 - if elapsed > 0 { - rate = float64(scannedBytes) / elapsed - } - sendScanStatus(progress, ScanStatus{ - TotalFiles: totalFiles, - ScannedFiles: scannedFiles, - TotalBytes: totalBytes, - ScannedBytes: scannedBytes, - BytesPerSec: rate, - }) - } - - // Build and write manifest - return builder.Build(w) -} - -// pathIsHidden returns true if the path or any of its parent directories -// start with a dot (hidden files/directories). -func pathIsHidden(p string) bool { - tp := path.Clean(p) - if strings.HasPrefix(tp, ".") { - return true - } - for { - d, f := path.Split(tp) - if strings.HasPrefix(f, ".") { - return true - } - if d == "" { - return false - } - tp = d[0 : len(d)-1] // trim trailing slash from dir - } -} - -// sendEnumerateStatus sends a status update without blocking. -// If the channel is full, the update is dropped. -func sendEnumerateStatus(ch chan<- EnumerateStatus, status EnumerateStatus) { - if ch == nil { - return - } - select { - case ch <- status: - default: - // Channel full, drop this update - } -} - -// sendScanStatus sends a status update without blocking. -// If the channel is full, the update is dropped. -func sendScanStatus(ch chan<- ScanStatus, status ScanStatus) { - if ch == nil { - return - } - select { - case ch <- status: - default: - // Channel full, drop this update - } -}