Repository: kopia/kopia
Pull request #4686 (2025-06-23 12:12:29)

feat(cli): add --disable-file-logging to suppress log file output
This patch introduces a new CLI flag `--disable-file-logging` that allows users to prevent Kopia from writing any log files to disk.

When enabled, this flag disables all file-based logging mechanisms, including the creation of log files and directories.

This is particularly useful in containerized or ephemeral environments where persistent log files are undesirable.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/4686?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Attention: Patch coverage is `78.57143%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 76.33%. Comparing base [(`cb455c6`)](https://app.codecov.io/gh/kopia/kopia/commit/cb455c699a04e54cb47e25a676d7d65063ce883e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) to head [(`4b3cb6a`)](https://app.codecov.io/gh/kopia/kopia/commit/4b3cb6ad9db24109740e65dbb82c302b9c4f8276?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> Report is 578 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/kopia/kopia/pull/4686?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Patch % | Lines |
|---|---|---|
| [internal/logfile/logfile.go](https://app.codecov.io/gh/kopia/kopia/pull/4686?src=pr&el=tree&filepath=internal%2Flogfile%2Flogfile.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-aW50ZXJuYWwvbG9nZmlsZS9sb2dmaWxlLmdv) | 78.57% | [1 Missing and 2 partials :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4686?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4686      +/-   ##
==========================================
+ Coverage   75.86%   76.33%   +0.46%     
==========================================
  Files         470      529      +59     
  Lines       37301    40193    +2892     
==========================================
+ Hits        28299    30680    +2381     
- Misses       7071     7482     +411     
- Partials     1931     2031     +100     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/4686?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/cli/command_snapshot_fix_invalid_files.go b/cli/command_snapshot_fix_invalid_files.go
index 27514f0e..9c81ce35 100644
--- a/cli/command_snapshot_fix_invalid_files.go
+++ b/cli/command_snapshot_fix_invalid_files.go
@@ -34,7 +34,7 @@ func (c *commandSnapshotFixInvalidFiles) setup(svc appServices, parent commandPa
 
 func (c *commandSnapshotFixInvalidFiles) rewriteEntry(ctx context.Context, pathFromRoot string, ent *snapshot.DirEntry) (*snapshot.DirEntry, error) {
 	if ent.Type != snapshot.EntryTypeDirectory {
-		if err := c.verifier.VerifyFile(ctx, ent.ObjectID, pathFromRoot); err != nil {
+		if err := c.verifier.VerifyFile(ctx, ent.ObjectID, pathFromRoot, ent.FileSize); err != nil {
 			log(ctx).Warnf("removing invalid file %v due to: %v", pathFromRoot, err)
 
 			return c.failedFileCallback(ctx, pathFromRoot, ent, err)
diff --git a/cli/command_snapshot_verify.go b/cli/command_snapshot_verify.go
index 1453c5c1..b8b3f382 100644
--- a/cli/command_snapshot_verify.go
+++ b/cli/command_snapshot_verify.go
@@ -7,6 +7,7 @@
 
 	"github.com/pkg/errors"
 
+	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/blob"
 	"github.com/kopia/kopia/repo/manifest"
@@ -26,6 +27,9 @@ type commandSnapshotVerify struct {
 
 	fileQueueLength int
 	fileParallelism int
+
+	jo  jsonOutput
+	out textOutput
 }
 
 func (c *commandSnapshotVerify) setup(svc appServices, parent commandParent) {
@@ -42,6 +46,10 @@ func (c *commandSnapshotVerify) setup(svc appServices, parent commandParent) {
 	cmd.Flag("file-queue-length", "Queue length for file verification").Default("20000").IntVar(&c.fileQueueLength)
 	cmd.Flag("file-parallelism", "Parallelism for file verification").IntVar(&c.fileParallelism)
 	cmd.Flag("verify-files-percent", "Randomly verify a percentage of files by downloading them [0.0 .. 100.0]").Default("0").Float64Var(&c.verifyCommandFilesPercent)
+
+	c.jo.setup(svc, cmd)
+	c.out.setup(svc)
+
 	cmd.Action(svc.repositoryReaderAction(c.run))
 }
 
@@ -59,6 +67,7 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 		FileQueueLength:    c.fileQueueLength,
 		Parallelism:        c.fileParallelism,
 		MaxErrors:          c.verifyCommandErrorThreshold,
+		JSONStats:          c.jo.jsonOutput,
 	}
 
 	if dr, ok := rep.(repo.DirectRepository); ok {
@@ -71,10 +80,26 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 	}
 
 	v := snapshotfs.NewVerifier(ctx, rep, opts)
-	defer v.ShowFinalStats(ctx)
+
+	defer func() {
+		// Suppress final stats output if --json flag provided.
+		if !c.jo.jsonOutput {
+			v.ShowFinalStats(ctx)
+		}
+	}()
+
+	result, err := v.InParallel(ctx, c.makeVerifyWalkerFunc(ctx, rep, v))
+
+	if c.jo.jsonOutput {
+		c.out.printStdout("%s\n", c.jo.jsonIndentedBytes(result, "  "))
+	}
 
 	//nolint:wrapcheck
-	return v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+	return err
+}
+
+func (c *commandSnapshotVerify) makeVerifyWalkerFunc(ctx context.Context, rep repo.Repository, v *snapshotfs.Verifier) func(tw *snapshotfs.TreeWalker) error {
+	return func(tw *snapshotfs.TreeWalker) error {
 		manifests, err := c.loadSourceManifests(ctx, rep)
 		if err != nil {
 			return err
@@ -87,6 +112,13 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 
 		manifests = append(manifests, snapIDManifests...)
 
+		type twEntry struct {
+			root     fs.Entry
+			rootPath string
+		}
+
+		var treeWalkerEntries []twEntry
+
 		for _, man := range manifests {
 			rootPath := fmt.Sprintf("%v@%v", man.Source, formatTimestamp(man.StartTime.ToTime()))
 
@@ -99,9 +131,20 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 				return errors.Wrapf(err, "unable to get snapshot root: %q", rootPath)
 			}
 
+			treeWalkerEntries = append(treeWalkerEntries, twEntry{
+				root:     root,
+				rootPath: rootPath,
+			})
+
+			if err := addExpectedWorkFromDirSummaryToVerifier(ctx, v, root); err != nil {
+				return errors.Wrapf(err, "unable to set stat totals from summary")
+			}
+		}
+
+		for _, twEntry := range treeWalkerEntries {
 			// ignore error now, return aggregate error at a higher level.
 			//nolint:errcheck
-			tw.Process(ctx, root, rootPath)
+			tw.Process(ctx, twEntry.root, twEntry.rootPath)
 		}
 
 		for _, oidStr := range c.verifyCommandDirObjectIDs {
@@ -127,7 +170,39 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 		}
 
 		return nil
-	})
+	}
+}
+
+// addExpectedWorkFromDirSummaryToVerifier initializes the snapshot verifier with an
+// expected amount of work that will take place during the tree walk for this Entry.
+// If the entry is not a DirectoryWithSummary, or the Summary returns nil, no stats
+// will be added to the totals.
+func addExpectedWorkFromDirSummaryToVerifier(ctx context.Context, v *snapshotfs.Verifier, ent fs.Entry) error {
+	dws, ok := ent.(fs.DirectoryWithSummary)
+	if !ok {
+		// Entry is not a directory with summary, no stats to add.
+		return nil
+	}
+
+	s, err := dws.Summary(ctx)
+	if err != nil {
+		return errors.Wrap(err, "unable to get directory summary")
+	}
+
+	if s == nil {
+		// Summary returned nil, no stats to add.
+		return nil
+	}
+
+	// Add the maximum expected work that could be done by this walk.
+	// This can be used to gauge progress.
+	v.AddToExpectedTotals(
+		s.TotalFileCount+s.TotalDirCount+s.TotalSymlinkCount,
+		s.TotalFileCount,
+		s.TotalFileSize,
+	)
+
+	return nil
 }
 
 func (c *commandSnapshotVerify) loadSourceManifests(ctx context.Context, rep repo.Repository) ([]*snapshot.Manifest, error) {
diff --git a/go.mod b/go.mod
index 51c3b247..2a253e5b 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
 	github.com/chmduquesne/rollinghash v4.0.0+incompatible
 	github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b
-	github.com/chromedp/chromedp v0.13.6
+	github.com/chromedp/chromedp v0.13.7
 	github.com/coreos/go-systemd/v22 v22.5.0
 	github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0
 	github.com/edsrzf/mmap-go v1.2.0
@@ -45,7 +45,7 @@ require (
 	github.com/pkg/sftp v1.13.9
 	github.com/prometheus/client_golang v1.22.0
 	github.com/prometheus/client_model v0.6.2
-	github.com/prometheus/common v0.64.0
+	github.com/prometheus/common v0.65.0
 	github.com/sanity-io/litter v1.5.8
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/stretchr/testify v1.10.0
diff --git a/go.sum b/go.sum
index 20f89ce2..57544f3e 100644
--- a/go.sum
+++ b/go.sum
@@ -63,8 +63,8 @@ github.com/chmduquesne/rollinghash v4.0.0+incompatible h1:hnREQO+DXjqIw3rUTzWN7/
 github.com/chmduquesne/rollinghash v4.0.0+incompatible/go.mod h1:Uc2I36RRfTAf7Dge82bi3RU0OQUmXT9iweIcPqvr8A0=
 github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b h1:jJmiCljLNTaq/O1ju9Bzz2MPpFlmiTn0F7LwCoeDZVw=
 github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
-github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
-github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
+github.com/chromedp/chromedp v0.13.7 h1:vt+mslxscyvUr58eC+6DLSeeo74jpV/HI2nWetjv/W4=
+github.com/chromedp/chromedp v0.13.7/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
 github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
 github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -247,8 +247,8 @@ github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/
 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
-github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
 github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
diff --git a/internal/logfile/logfile.go b/internal/logfile/logfile.go
index 2a3138a9..6f3cedd5 100644
--- a/internal/logfile/logfile.go
+++ b/internal/logfile/logfile.go
@@ -52,6 +52,7 @@ type loggingFlags struct {
 	disableColor                bool
 	consoleLogTimestamps        bool
 	waitForLogSweep             bool
+	disableFileLogging          bool
 
 	cliApp *cli.App
 }
@@ -59,6 +60,7 @@ type loggingFlags struct {
 func (c *loggingFlags) setup(cliApp *cli.App, app *kingpin.Application) {
 	app.Flag("log-file", "Override log file.").StringVar(&c.logFile)
 	app.Flag("content-log-file", "Override content log file.").Hidden().StringVar(&c.contentLogFile)
+	app.Flag("disable-file-logging", "Disable file-based logging.").BoolVar(&c.disableFileLogging)
 
 	app.Flag("log-dir", "Directory where log files should be written.").Envar(cliApp.EnvName("KOPIA_LOG_DIR")).Default(ospath.LogsDir()).StringVar(&c.logDir)
 	app.Flag("log-dir-max-files", "Maximum number of log files to retain").Envar(cliApp.EnvName("KOPIA_LOG_DIR_MAX_FILES")).Default("1000").Hidden().IntVar(&c.logDirMaxFiles)
@@ -113,12 +115,21 @@ func (c *loggingFlags) initialize(ctx *kingpin.ParseContext) error {
 		suffix = strings.ReplaceAll(c.FullCommand(), " ", "-")
 	}
 
-	rootLogger := zap.New(zapcore.NewTee(
-		c.setupConsoleCore(),
-		c.setupLogFileCore(now, suffix),
-	), zap.WithClock(zaplogutil.Clock()))
+	rootCores := []zapcore.Core{c.setupConsoleCore()}
+	if !c.disableFileLogging {
+		rootCores = append(rootCores, c.setupLogFileCore(now, suffix))
+	}
+
+	rootLogger := zap.New(zapcore.NewTee(rootCores...), zap.WithClock(zaplogutil.Clock()))
+
+	var contentCore zapcore.Core
+	if c.disableFileLogging {
+		contentCore = c.setupConsoleCore()
+	} else {
+		contentCore = c.setupContentLogFileBackend(now, suffix)
+	}
 
-	contentLogger := zap.New(c.setupContentLogFileBackend(now, suffix), zap.WithClock(zaplogutil.Clock())).Sugar()
+	contentLogger := zap.New(contentCore, zap.WithClock(zaplogutil.Clock())).Sugar()
 
 	c.cliApp.SetLoggerFactory(func(module string) logging.Logger {
 		if module == content.FormatLogModule {
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index ed03fd5a..c5a54984 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -10,6 +10,7 @@
 	"time"
 
 	"github.com/kopia/kopia/internal/clock"
+	"github.com/kopia/kopia/internal/sleepable"
 	"github.com/kopia/kopia/repo/logging"
 )
 
@@ -98,15 +99,6 @@ func (s *Scheduler) upcomingItems(ctx context.Context, now time.Time) (nextTrigg
 	return nextTriggerTime, toTrigger
 }
 
-func sleepTimeOrDefault(now, t time.Time, def time.Duration) time.Duration {
-	if t.IsZero() {
-		return def
-	}
-
-	// note this may negative if the getItems returns items in the past.
-	return t.Sub(now)
-}
-
 // Stop stops the scheduler.
 func (s *Scheduler) Stop() {
 	close(s.closed)
@@ -114,21 +106,20 @@ func (s *Scheduler) Stop() {
 }
 
 func (s *Scheduler) run(ctx context.Context) {
-	var timer *time.Timer
+	var timer *sleepable.Timer
 
 	for {
 		now := s.TimeNow()
 		nextTriggerTime, toTrigger := s.upcomingItems(ctx, now)
 
-		sleepTimeUntilNextTrigger := sleepTimeOrDefault(now, nextTriggerTime, sleepTimeWhenNoUpcomingSnapshots)
-		if sleepTimeUntilNextTrigger < 0 {
-			sleepTimeUntilNextTrigger = 0
+		targetTime := nextTriggerTime
+		if targetTime.IsZero() {
+			targetTime = now.Add(sleepTimeWhenNoUpcomingSnapshots)
 		}
 
-		if s.Debug && sleepTimeUntilNextTrigger > 0 {
-			log(ctx).Debugf("sleeping for %v until %v (%v)",
-				sleepTimeUntilNextTrigger,
-				nextTriggerTime.Format(time.RFC3339),
+		if s.Debug {
+			log(ctx).Debugf("sleeping until %v (%v)",
+				targetTime.Format(time.RFC3339),
 				TriggerNames(toTrigger),
 			)
 		}
@@ -138,11 +129,13 @@ func (s *Scheduler) run(ctx context.Context) {
 			timer.Stop()
 		}
 
-		timer = time.NewTimer(sleepTimeUntilNextTrigger)
+		timer = sleepable.NewTimer(s.TimeNow, targetTime)
 
 		select {
 		case <-s.closed:
 			// stopping, just exit
+			timer.Stop()
+
 			return
 
 		case <-timer.C:
diff --git a/internal/sleepable/sleepable_timer.go b/internal/sleepable/sleepable_timer.go
new file mode 100644
index 00000000..a4c6cfad
--- /dev/null
+++ b/internal/sleepable/sleepable_timer.go
@@ -0,0 +1,82 @@
+// Package sleepable implements a timer that can trigger at or soon after a given time and supports the computer going to sleep while the timer is waiting.
+package sleepable
+
+import (
+	"sync"
+	"time"
+)
+
+// MaxSleepTime is the maximum duration the timer will sleep in one interval. Can be overridden for testing.
+var MaxSleepTime = 15 * time.Second
+
+// Timer is a timer that can trigger at or soon after a given time and supports the computer going to sleep while the timer is waiting.
+type Timer struct {
+	C        <-chan struct{}
+	stopChan chan struct{}
+	stopOnce sync.Once
+}
+
+// Stop stops the timer.
+func (c *Timer) Stop() {
+	c.stopOnce.Do(func() {
+		close(c.stopChan)
+	})
+}
+
+// NewTimer creates a new timer that will trigger at the given time.
+func NewTimer(nowFunc func() time.Time, until time.Time) *Timer {
+	ch := make(chan struct{})
+
+	t := &Timer{
+		C:        ch,
+		stopChan: make(chan struct{}),
+	}
+
+	var currentTimer *time.Timer
+
+	// capture maxSleepTime at the start of the goroutine to avoid race conditions.
+	maxSleepTime := MaxSleepTime
+
+	// start a goroutine that will sleep until the timer is triggered or the timer is stopped.
+	go func() {
+		defer func() {
+			if currentTimer != nil {
+				currentTimer.Stop()
+			}
+		}()
+
+		for {
+			now := nowFunc()
+
+			// when the current time is after the target time, the timer immediately triggers by closing the channel.
+			if now.After(until) {
+				close(ch)
+				return
+			}
+
+			nextSleepTime := until.Sub(now)
+			if nextSleepTime > maxSleepTime {
+				nextSleepTime = maxSleepTime
+			}
+
+			if currentTimer != nil {
+				currentTimer.Stop()
+			}
+
+			currentTimer = time.NewTimer(nextSleepTime)
+
+			select {
+			case <-t.stopChan:
+				// stop channel was closed, exit without closing the "C" channel
+				return
+
+			case <-currentTimer.C:
+				// timer did trigger, close the channel to signal that sleepable.Timer.C is done
+				close(ch)
+				return
+			}
+		}
+	}()
+
+	return t
+}
diff --git a/repo/encryption/encryption.go b/repo/encryption/encryption.go
index 22b36156..91d9f2e4 100644
--- a/repo/encryption/encryption.go
+++ b/repo/encryption/encryption.go
@@ -2,12 +2,11 @@
 package encryption
 
 import (
+	"crypto/hkdf"
 	"crypto/sha256"
-	"io"
 	"sort"
 
 	"github.com/pkg/errors"
-	"golang.org/x/crypto/hkdf"
 
 	"github.com/kopia/kopia/internal/gather"
 )
@@ -97,9 +96,10 @@ func deriveKey(p Parameters, purpose []byte, length int) ([]byte, error) {
 		return nil, errors.Errorf("derived key must be at least %d bytes, was %v", minDerivedKeyLength, length)
 	}
 
-	key := make([]byte, length)
-	k := hkdf.New(sha256.New, p.GetMasterKey(), purpose, nil)
-	io.ReadFull(k, key) //nolint:errcheck
+	derivedKey, err := hkdf.Key(sha256.New, p.GetMasterKey(), purpose, "", length)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to derive key")
+	}
 
-	return key, nil
+	return derivedKey, nil
 }
diff --git a/snapshot/snapshotfs/snapshot_tree_walker.go b/snapshot/snapshotfs/snapshot_tree_walker.go
index 07adf7c2..d28a41d1 100644
--- a/snapshot/snapshotfs/snapshot_tree_walker.go
+++ b/snapshot/snapshotfs/snapshot_tree_walker.go
@@ -58,6 +58,15 @@ func (w *TreeWalker) ReportError(ctx context.Context, entryPath string, err erro
 	w.numErrors++
 }
 
+// GetErrors returns a copy of the list of errors found during the tree walk, as well
+// as the total count of errors encountered.
+func (w *TreeWalker) GetErrors() (errs []error, numErrors int) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	return append([]error{}, w.errors...), w.numErrors
+}
+
 // Err returns the error encountered when walking the tree.
 func (w *TreeWalker) Err() error {
 	w.mu.Lock()
diff --git a/snapshot/snapshotfs/snapshot_verifier.go b/snapshot/snapshotfs/snapshot_verifier.go
index 5ce5cd54..4a6a9e17 100644
--- a/snapshot/snapshotfs/snapshot_verifier.go
+++ b/snapshot/snapshotfs/snapshot_verifier.go
@@ -2,11 +2,11 @@
 
 import (
 	"context"
+	"encoding/json"
 	"io"
 	"math/rand"
 	"runtime"
 	"sync"
-	"sync/atomic"
 	"time"
 
 	"github.com/pkg/errors"
@@ -14,6 +14,7 @@
 	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/internal/iocopy"
 	"github.com/kopia/kopia/internal/timetrack"
+	"github.com/kopia/kopia/internal/units"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/blob"
 	"github.com/kopia/kopia/repo/logging"
@@ -25,14 +26,31 @@
 type verifyFileWorkItem struct {
 	oid       object.ID
 	entryPath string
+	size      int64
 }
 
 // Verifier allows efficient verification of large amounts of filesystem entries in parallel.
 type Verifier struct {
 	throttle timetrack.Throttle
 
-	queued    atomic.Int32
-	processed atomic.Int32
+	statsMu sync.RWMutex
+
+	// +checklocks:statsMu
+	queued int32
+	// +checklocks:statsMu
+	processed int32
+	// +checklocks:statsMu
+	processedBytes int64
+	// +checklocks:statsMu
+	readFiles int64
+	// +checklocks:statsMu
+	readBytes int64
+	// +checklocks:statsMu
+	expectedTotalObjects int64
+	// +checklocks:statsMu
+	expectedTotalFiles int64
+	// +checklocks:statsMu
+	expectedTotalBytes int64
 
 	fileWorkQueue chan verifyFileWorkItem
 	rep           repo.Repository
@@ -42,26 +60,97 @@ type Verifier struct {
 	blobMap map[blob.ID]blob.Metadata // when != nil, will check that each backing blob exists
 }
 
+// AddToExpectedTotals adds the provided values to the corresponding stat
+// total values. If the caller can precompute the expected number
+// of entries that will be iterated over, they can add them to
+// the progress output.
+func (v *Verifier) AddToExpectedTotals(objs, files, bytes int64) {
+	v.statsMu.Lock()
+	defer v.statsMu.Unlock()
+
+	v.expectedTotalObjects += objs
+	v.expectedTotalFiles += files
+	v.expectedTotalBytes += bytes
+}
+
+func (v *Verifier) getStats() VerifierStats {
+	v.statsMu.RLock()
+	defer v.statsMu.RUnlock()
+
+	return VerifierStats{
+		ProcessedObjectCount: int64(v.processed),
+		ProcessedBytes:       v.processedBytes,
+		ReadFileCount:        v.readFiles,
+		ReadBytes:            v.readBytes,
+		ExpectedTotalObjects: v.expectedTotalObjects,
+		ExpectedTotalFiles:   v.expectedTotalFiles,
+		ExpectedTotalBytes:   v.expectedTotalBytes,
+	}
+}
+
+const (
+	inProgressStatsMsg = "Processed"
+	finishedStatsMsg   = "Finished processing"
+)
+
 // ShowStats logs verification statistics.
 func (v *Verifier) ShowStats(ctx context.Context) {
-	processed := v.processed.Load()
-
-	verifierLog(ctx).Infof("Processed %v objects.", processed)
+	v.showStatsf(ctx, inProgressStatsMsg)
 }
 
 // ShowFinalStats logs final verification statistics.
 func (v *Verifier) ShowFinalStats(ctx context.Context) {
-	processed := v.processed.Load()
+	v.showStatsf(ctx, finishedStatsMsg)
+}
+
+func (v *Verifier) showStatsf(ctx context.Context, msg string) {
+	st := v.getStats()
+
+	if v.opts.JSONStats {
+		v.showStatsJSON(ctx, st)
+		return
+	}
+
+	processed := st.ProcessedObjectCount
+	processedBytes := st.ProcessedBytes
+	readFiles := st.ReadFileCount
+	readBytes := st.ReadBytes
+
+	// "<message> <x> objects. Read <y> files (<z> MB)"
+	verifierLog(ctx).Infof("%v %v objects (%v). Read %v files (%v).", msg, processed, units.BytesString(processedBytes), readFiles, units.BytesString(readBytes))
+}
+
+// VerifierStats contains stats on the amount of work done and current progress.
+type VerifierStats struct {
+	ProcessedObjectCount int64 `json:"processedObjectCount"`
+	ProcessedBytes       int64 `json:"processedBytes"`
+	ReadFileCount        int64 `json:"readFileCount"`
+	ReadBytes            int64 `json:"readBytes"`
+	ExpectedTotalObjects int64 `json:"expectedTotalObjectCount"`
+	ExpectedTotalFiles   int64 `json:"expectedTotalFileCount"`
+	ExpectedTotalBytes   int64 `json:"expectedTotalBytes"`
+}
+
+func (v *Verifier) showStatsJSON(ctx context.Context, st VerifierStats) {
+	b, err := json.Marshal(st)
+	if err != nil {
+		verifierLog(ctx).Errorw("failed to marshal stats", "err", err, "stats", st)
+		return
+	}
 
-	verifierLog(ctx).Infof("Finished processing %v objects.", processed)
+	verifierLog(ctx).Infof("%s", b)
 }
 
 // VerifyFile verifies a single file object (using content check, blob map check or full read).
-func (v *Verifier) VerifyFile(ctx context.Context, oid object.ID, entryPath string) error {
+func (v *Verifier) VerifyFile(ctx context.Context, oid object.ID, entryPath string, size int64) error {
 	verifierLog(ctx).Debugf("verifying object %v", oid)
 
 	defer func() {
-		v.processed.Add(1)
+		v.updateProcessedStats(size)
+
+		if v.throttle.ShouldOutput(time.Second) {
+			v.ShowStats(ctx)
+		}
 	}()
 
 	contentIDs, err := v.rep.VerifyObject(ctx, oid)
@@ -92,6 +181,14 @@ func (v *Verifier) VerifyFile(ctx context.Context, oid object.ID, entryPath stri
 	return nil
 }
 
+func (v *Verifier) updateProcessedStats(size int64) {
+	v.statsMu.Lock()
+	defer v.statsMu.Unlock()
+
+	v.processed++
+	v.processedBytes += size
+}
+
 // verifyObject enqueues a single object for verification.
 func (v *Verifier) verifyObject(ctx context.Context, e fs.Entry, oid object.ID, entryPath string) error {
 	if v.throttle.ShouldOutput(time.Second) {
@@ -99,11 +196,18 @@ func (v *Verifier) verifyObject(ctx context.Context, e fs.Entry, oid object.ID,
 	}
 
 	if !e.IsDir() {
-		v.fileWorkQueue <- verifyFileWorkItem{oid, entryPath}
-		v.queued.Add(1)
+		v.fileWorkQueue <- verifyFileWorkItem{oid, entryPath, e.Size()}
+
+		v.statsMu.Lock()
+		defer v.statsMu.Unlock()
+
+		v.queued++
 	} else {
-		v.queued.Add(1)
-		v.processed.Add(1)
+		v.statsMu.Lock()
+		defer v.statsMu.Unlock()
+
+		v.queued++
+		v.processed++
 	}
 
 	return nil
@@ -119,7 +223,18 @@ func (v *Verifier) readEntireObject(ctx context.Context, oid object.ID, path str
 	}
 	defer r.Close() //nolint:errcheck
 
-	return errors.Wrap(iocopy.JustCopy(io.Discard, r), "unable to read data")
+	n, err := iocopy.Copy(io.Discard, r)
+	if err != nil {
+		return errors.Wrap(err, "unable to read data")
+	}
+
+	v.statsMu.Lock()
+	defer v.statsMu.Unlock()
+
+	v.readBytes += n
+	v.readFiles++
+
+	return nil
 }
 
 // VerifierOptions provides options for the verifier.
@@ -129,18 +244,31 @@ type VerifierOptions struct {
 	Parallelism        int
 	MaxErrors          int
 	BlobMap            map[blob.ID]blob.Metadata
+	JSONStats          bool
+}
+
+// VerifierResult returns results from the verifier.
+type VerifierResult struct {
+	Stats        VerifierStats `json:"stats"`
+	ErrorCount   int           `json:"errorCount"`
+	Errors       []error       `json:"-"`
+	ErrorStrings []string      `json:"errorStrings,omitempty"`
 }
 
-// InParallel starts parallel verification and invokes the provided function which can
-// call Process() on in the provided TreeWalker.
-func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker) error) error {
+// InParallel starts parallel verification and invokes the provided function
+// which can call Process() on in the provided TreeWalker. Errors and stats
+// are accumulated into a VerifierResult and returned, independent of whether
+// the error return is nil, that is, `VerifierResult` will contain useful,
+// partial stats when an error is returned, including a collection of errors
+// found in the verification process.
+func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker) error) (VerifierResult, error) {
 	tw, twerr := NewTreeWalker(ctx, TreeWalkerOptions{
 		Parallelism:   v.opts.Parallelism,
 		EntryCallback: v.verifyObject,
 		MaxErrors:     v.opts.MaxErrors,
 	})
 	if twerr != nil {
-		return errors.Wrap(twerr, "tree walker")
+		return VerifierResult{}, errors.Wrap(twerr, "tree walker")
 	}
 	defer tw.Close(ctx)
 
@@ -157,7 +285,7 @@ func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker)
 					continue
 				}
 
-				if err := v.VerifyFile(ctx, wi.oid, wi.entryPath); err != nil {
+				if err := v.VerifyFile(ctx, wi.oid, wi.entryPath, wi.size); err != nil {
 					tw.ReportError(ctx, wi.entryPath, err)
 				}
 			}
@@ -165,16 +293,29 @@ func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker)
 	}
 
 	err := enqueue(tw)
+	if err != nil {
+		// Pass the enqueue error to the tree walker for later accumulation.
+		tw.ReportError(ctx, "tree walker enqueue", err)
+	}
 
 	close(v.fileWorkQueue)
 	v.workersWG.Wait()
 	v.fileWorkQueue = nil
 
-	if err != nil {
-		return err
+	twErrs, numErrors := tw.GetErrors()
+
+	errStrs := make([]string, 0, len(twErrs))
+	for _, twErr := range twErrs {
+		errStrs = append(errStrs, twErr.Error())
 	}
 
-	return tw.Err()
+	// Return the tree walker error output along with result details.
+	return VerifierResult{
+		Stats:        v.getStats(),
+		Errors:       twErrs,
+		ErrorStrings: errStrs,
+		ErrorCount:   numErrors,
+	}, tw.Err()
 }
 
 // NewVerifier creates a verifier.


Test patch:
diff --git a/cli/command_snapshot_verify_test.go b/cli/command_snapshot_verify_test.go
index e211556b..b495e7e6 100644
--- a/cli/command_snapshot_verify_test.go
+++ b/cli/command_snapshot_verify_test.go
@@ -2,6 +2,7 @@
 
 import (
 	"bytes"
+	"encoding/json"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -11,6 +12,7 @@
 
 	"github.com/kopia/kopia/internal/testutil"
 	"github.com/kopia/kopia/snapshot"
+	"github.com/kopia/kopia/snapshot/snapshotfs"
 	"github.com/kopia/kopia/tests/testenv"
 )
 
@@ -76,4 +78,73 @@ func TestSnapshotVerify(t *testing.T) {
 
 	// Requesting a snapshot verify of a non-existent manifest ID results in error.
 	env.RunAndExpectFailure(t, "snapshot", "verify", "not-a-manifest-id")
+
+	// Redo the above snapshot verify commands with the --json flag and check the outputs.
+
+	// Verifying everything is expected to fail.
+	stdout, _ := env.RunAndExpectFailure(t, "snapshot", "verify", "--json")
+	require.Len(t, stdout, 1)
+	result := unmarshalSnapVerify(t, stdout[0])
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
+	require.NotZero(t, result.ErrorCount)
+	require.Len(t, result.ErrorStrings, 1)
+
+	// Verifying the untouched snapshot is expected to succeed.
+	stdout = env.RunAndExpectSuccess(t, "snapshot", "verify", "--json", string(intactMan.ID))
+	require.Len(t, stdout, 1)
+	result = unmarshalSnapVerify(t, stdout[0])
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
+	require.Zero(t, result.ErrorCount)
+	require.Empty(t, result.ErrorStrings)
+
+	// Verifying the corrupted snapshot is expected to fail.
+	stdout, _ = env.RunAndExpectFailure(t, "snapshot", "verify", "--json", string(corruptMan1.ID))
+	require.Len(t, stdout, 1)
+	result = unmarshalSnapVerify(t, stdout[0])
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
+	require.NotZero(t, result.ErrorCount)
+	require.Len(t, result.ErrorStrings, 1)
+
+	// Verifying the corrupted snapshot is expected to fail.
+	stdout, _ = env.RunAndExpectFailure(t, "snapshot", "verify", "--json", string(corruptMan2.ID))
+	require.Len(t, stdout, 1)
+	result = unmarshalSnapVerify(t, stdout[0])
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
+	require.NotZero(t, result.ErrorCount)
+	require.Len(t, result.ErrorStrings, 1)
+
+	// Find one matching error corresponding to the single corrupted contents.
+	stdout, stderr, err = env.Run(t, true, "snapshot", "verify", "--json", "--max-errors", "3", string(corruptMan1.ID))
+	require.Error(t, err)
+	assert.Equal(t, 1, strings.Count(strings.Join(stderr, "\n"), "error processing"))
+	result = unmarshalSnapVerify(t, stdout[0])
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
+	require.Equal(t, 1, result.ErrorCount)
+	require.Len(t, result.ErrorStrings, 1)
+
+	// Find two matching errors in the verify output, corresponding to each
+	// of the two corrupted contents.
+	stdout, stderr, err = env.Run(t, true, "snapshot", "verify", "--json", "--max-errors", "3", string(corruptMan2.ID))
+	require.Error(t, err)
+	assert.Equal(t, 2, strings.Count(strings.Join(stderr, "\n"), "error processing"))
+	result = unmarshalSnapVerify(t, stdout[0])
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
+	require.Equal(t, 2, result.ErrorCount)
+	require.Len(t, result.ErrorStrings, 2)
+
+	// Requesting a snapshot verify of a non-existent manifest ID results in error.
+	stdout, _ = env.RunAndExpectFailure(t, "snapshot", "verify", "--json", "not-a-manifest-id")
+	result = unmarshalSnapVerify(t, stdout[0])
+	require.Zero(t, result.Stats.ProcessedObjectCount)
+	require.Equal(t, 1, result.ErrorCount)
+	require.Len(t, result.ErrorStrings, 1)
+}
+
+func unmarshalSnapVerify(t *testing.T, stdout string) *snapshotfs.VerifierResult {
+	t.Helper()
+
+	r := &snapshotfs.VerifierResult{}
+	require.NoError(t, json.Unmarshal([]byte(stdout), r))
+
+	return r
 }
diff --git a/internal/sleepable/sleepable_timer_test.go b/internal/sleepable/sleepable_timer_test.go
new file mode 100644
index 00000000..84f9f5d4
--- /dev/null
+++ b/internal/sleepable/sleepable_timer_test.go
@@ -0,0 +1,222 @@
+package sleepable
+
+import (
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/kopia/kopia/internal/clock"
+)
+
+const testMaxSleepTime = 10 * time.Millisecond
+
+// setMaxSleepTimeForTest sets MaxSleepTime to the given duration and registers a cleanup function
+// to restore the original value when the test completes.
+//
+//nolint:unparam
+func setMaxSleepTimeForTest(t *testing.T, duration time.Duration) {
+	t.Helper()
+
+	originalMaxSleepTime := MaxSleepTime
+	MaxSleepTime = duration
+
+	t.Cleanup(func() {
+		MaxSleepTime = originalMaxSleepTime
+	})
+}
+
+func TestNewTimer(t *testing.T) {
+	// Set a small MaxSleepTime for testing
+	setMaxSleepTimeForTest(t, testMaxSleepTime)
+
+	tests := []struct {
+		name     string
+		duration time.Duration
+		expected time.Duration
+	}{
+		{
+			name:     "short duration",
+			duration: 20 * time.Millisecond,
+			expected: 20 * time.Millisecond,
+		},
+		{
+			name:     "long duration capped at maxSleepTime",
+			duration: 1 * time.Second,
+			expected: testMaxSleepTime,
+		},
+		{
+			name:     "exactly maxSleepTime",
+			duration: testMaxSleepTime,
+			expected: testMaxSleepTime,
+		},
+		{
+			name:     "zero duration",
+			duration: 0,
+			expected: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			start := clock.Now()
+			target := start.Add(tt.duration)
+
+			timer := NewTimer(clock.Now, target)
+
+			// Wait for timer to trigger
+			<-timer.C
+
+			elapsed := clock.Now().Sub(start)
+
+			if tt.duration == 0 {
+				if elapsed > 10*time.Millisecond {
+					t.Errorf("zero duration timer took too long: %v", elapsed)
+				}
+
+				return
+			}
+
+			if tt.duration <= testMaxSleepTime {
+				if elapsed < tt.duration-5*time.Millisecond || elapsed > tt.duration+50*time.Millisecond {
+					t.Errorf("timer triggered at wrong time: expected ~%v, got %v", tt.duration, elapsed)
+				}
+			} else {
+				if elapsed < testMaxSleepTime-5*time.Millisecond || elapsed > testMaxSleepTime+50*time.Millisecond {
+					t.Errorf("long timer triggered at wrong time: expected ~%v, got %v", testMaxSleepTime, elapsed)
+				}
+			}
+		})
+	}
+}
+
+func TestTimerStop(t *testing.T) {
+	// Set a small MaxSleepTime for testing
+	setMaxSleepTimeForTest(t, testMaxSleepTime)
+
+	t.Run("stop before trigger", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(100 * time.Millisecond)
+
+		timer := NewTimer(clock.Now, target)
+		timer.Stop()
+		time.Sleep(20 * time.Millisecond)
+		select {
+		case <-timer.C:
+			t.Error("timer triggered after being stopped")
+		default:
+		}
+	})
+
+	t.Run("stop after trigger", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(10 * time.Millisecond)
+
+		timer := NewTimer(clock.Now, target)
+		<-timer.C
+		timer.Stop()
+	})
+}
+
+func TestTimerConcurrentStop(t *testing.T) {
+	// Set a small MaxSleepTime for testing
+	setMaxSleepTimeForTest(t, testMaxSleepTime)
+
+	t.Run("multiple stops", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(100 * time.Millisecond)
+
+		timer := NewTimer(clock.Now, target)
+
+		var wg sync.WaitGroup
+		for range 10 {
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				timer.Stop()
+			}()
+		}
+
+		wg.Wait()
+		time.Sleep(20 * time.Millisecond)
+		select {
+		case <-timer.C:
+			t.Error("timer triggered after being stopped")
+		default:
+		}
+	})
+}
+
+func TestTimerEdgeCases(t *testing.T) {
+	// Set a small MaxSleepTime for testing
+	setMaxSleepTimeForTest(t, testMaxSleepTime)
+
+	t.Run("past time", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(-1 * time.Second)
+		timer := NewTimer(clock.Now, target)
+		select {
+		case <-timer.C:
+		case <-time.After(10 * time.Millisecond):
+			t.Error("timer did not trigger immediately for past time")
+		}
+	})
+
+	t.Run("exactly now", func(t *testing.T) {
+		start := clock.Now()
+		target := start
+		timer := NewTimer(clock.Now, target)
+		select {
+		case <-timer.C:
+		case <-time.After(10 * time.Millisecond):
+			t.Error("timer did not trigger immediately for current time")
+		}
+	})
+
+	t.Run("very long duration", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(24 * time.Hour)
+		timer := NewTimer(clock.Now, target)
+		select {
+		case <-timer.C:
+			elapsed := clock.Now().Sub(start)
+			if elapsed < testMaxSleepTime-5*time.Millisecond || elapsed > testMaxSleepTime+50*time.Millisecond {
+				t.Errorf("very long timer triggered at wrong time: expected ~%v, got %v", testMaxSleepTime, elapsed)
+			}
+		case <-time.After(testMaxSleepTime + 100*time.Millisecond):
+			t.Error("very long timer did not trigger within expected time")
+		}
+	})
+}
+
+func TestTimerChannelBehavior(t *testing.T) {
+	// Set a small MaxSleepTime for testing
+	setMaxSleepTimeForTest(t, testMaxSleepTime)
+
+	t.Run("channel closed only once", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(10 * time.Millisecond)
+		timer := NewTimer(clock.Now, target)
+		<-timer.C
+		<-timer.C
+		<-timer.C
+		select {
+		case <-timer.C:
+		default:
+			t.Error("timer channel should remain closed after trigger")
+		}
+	})
+
+	t.Run("stopped timer channel not closed", func(t *testing.T) {
+		start := clock.Now()
+		target := start.Add(100 * time.Millisecond)
+		timer := NewTimer(clock.Now, target)
+		timer.Stop()
+		time.Sleep(20 * time.Millisecond)
+		select {
+		case <-timer.C:
+			t.Error("stopped timer channel should not be closed")
+		default:
+		}
+	})
+}
diff --git a/snapshot/snapshotfs/snapshot_verifier_test.go b/snapshot/snapshotfs/snapshot_verifier_test.go
index afbfccc8..d2e187ae 100644
--- a/snapshot/snapshotfs/snapshot_verifier_test.go
+++ b/snapshot/snapshotfs/snapshot_verifier_test.go
@@ -61,22 +61,48 @@ func TestSnapshotVerifier(t *testing.T) {
 
 		someErr := errors.New("some error")
 
-		require.ErrorIs(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err := v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			return someErr
-		}), someErr)
-
-		require.ErrorIs(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		})
+		require.ErrorIs(t, err, someErr)
+		require.Equal(t, 1, result.ErrorCount)
+		require.Len(t, result.Errors, 1)
+		require.ErrorIs(t, result.Errors[0], someErr)
+		require.Zero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
+
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			return someErr
-		}), someErr)
-
-		require.NoError(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		})
+		require.ErrorIs(t, err, someErr)
+		require.Equal(t, 1, result.ErrorCount)
+		require.Len(t, result.Errors, 1)
+		require.ErrorIs(t, result.Errors[0], someErr)
+		require.Zero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
+
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			return nil
-		}))
+		})
+		require.NoError(t, err)
+		require.Equal(t, 0, result.ErrorCount)
+		require.Empty(t, result.Errors)
+		require.Zero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 
-		require.NoError(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}))
+		})
+		require.NoError(t, err)
+		require.Equal(t, 0, result.ErrorCount)
+		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 	})
 
 	t.Run("FullFileReadsAndBlobMap", func(t *testing.T) {
@@ -93,10 +119,16 @@ func TestSnapshotVerifier(t *testing.T) {
 
 		v := snapshotfs.NewVerifier(ctx, te2, opts)
 
-		require.NoError(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err := v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}))
+		})
+		require.NoError(t, err)
+		require.Equal(t, 0, result.ErrorCount)
+		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 
 		// now remove all 'p' blobs from the blob map
 		for k := range opts.BlobMap {
@@ -105,10 +137,21 @@ func TestSnapshotVerifier(t *testing.T) {
 			}
 		}
 
-		require.ErrorContains(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}), "encountered 3 errors")
+		})
+		require.ErrorContains(t, err, "encountered 3 errors")
+		require.Equal(t, 3, result.ErrorCount)
+		require.Len(t, result.Errors, 3)
+
+		for _, err := range result.Errors {
+			require.ErrorContains(t, err, "is backed by missing blob")
+		}
+
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 	})
 
 	t.Run("MaxErrors", func(t *testing.T) {
@@ -125,10 +168,18 @@ func TestSnapshotVerifier(t *testing.T) {
 
 		v := snapshotfs.NewVerifier(ctx, te2, opts)
 
-		require.NoError(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		var result snapshotfs.VerifierResult
+
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}))
+		})
+		require.NoError(t, err)
+		require.Equal(t, 0, result.ErrorCount)
+		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 
 		// now remove all 'p' blobs from the blob map
 		for k := range opts.BlobMap {
@@ -138,10 +189,17 @@ func TestSnapshotVerifier(t *testing.T) {
 		}
 
 		// we have 3 errors but max==1
-		require.ErrorContains(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}), "is backed by missing blob")
+		})
+		require.ErrorContains(t, err, "is backed by missing blob")
+		require.Equal(t, 1, result.ErrorCount)
+		require.Len(t, result.Errors, 1)
+		require.ErrorContains(t, result.Errors[0], "is backed by missing blob")
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 	})
 
 	t.Run("FullFileReadsNoBlobMap", func(t *testing.T) {
@@ -151,10 +209,16 @@ func TestSnapshotVerifier(t *testing.T) {
 		}
 		v := snapshotfs.NewVerifier(ctx, te2, opts)
 
-		require.NoError(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err := v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}))
+		})
+		require.NoError(t, err)
+		require.Equal(t, 0, result.ErrorCount)
+		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 
 		blobs, err := blob.ListAllBlobs(ctx, te.RepositoryWriter.BlobReader(), "p")
 		require.NoError(t, err)
@@ -163,9 +227,21 @@ func TestSnapshotVerifier(t *testing.T) {
 			require.NoError(t, te.RepositoryWriter.BlobStorage().DeleteBlob(ctx, bm.BlobID))
 		}
 
-		require.ErrorContains(t, v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
+		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
 			return nil
-		}), "encountered 3 errors")
+		})
+
+		require.ErrorContains(t, err, "encountered 3 errors")
+		require.Equal(t, 3, result.ErrorCount)
+		require.Len(t, result.Errors, 3)
+
+		for _, err := range result.Errors {
+			require.ErrorContains(t, err, "BLOB not found")
+		}
+
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 	})
 }