fix(snapshots): inaccessible entry causes parent directory to be skipped (#5217)

Revert "feat(snapshots): localfs support for passing options (#5044)"
commit c8c4615595.

Fix: return `ErrorEntry` for permission denied instead of aborting

When iterating a directory, if `lstat` fails with permission denied on
an entry, return an `ErrorEntry` instead of an error that stops the
entire directory iteration.

Previously, a single inaccessible entry, such as, a FUSE/sshfs mount
owned by another user, would cause the entire containing directory to
fail and be omitted from the snapshot, resulting in data loss.

Now, the inaccessible entry is returned as an ErrorEntry which is
handled according to the configured error handling policy, allowing
iteration to continue and the rest of the directory to be backed up.

- Fixes: #5045

Differentiate entry type when ignoring failed entries
Fix tests for new behavior, including handling timing-dependent
behavior when snapshots --fail-fast

---------

Co-authored-by: Geoffrey D. Bennett <g@netcraft.com.au>
This commit is contained in:
Julio López
2026-03-19 12:24:38 -07:00
committed by GitHub
parent e37382478d
commit 4fe60817a0
5 changed files with 129 additions and 335 deletions

View File

@@ -13,16 +13,6 @@
const numEntriesToRead = 100 // number of directory entries to read in one shot
// Options contains configuration options for localfs operations.
type Options struct {
// IgnoreUnreadableDirEntries, when true, causes unreadable directory entries
// to be silently skipped during directory iteration instead of causing errors.
IgnoreUnreadableDirEntries bool
}
// DefaultOptions stores the default options used by localfs functions.
var DefaultOptions = &Options{}
type filesystemEntry struct {
name string
size int64
@@ -31,8 +21,7 @@ type filesystemEntry struct {
owner fs.OwnerInfo
device fs.DeviceInfo
prefix string
options *Options
prefix string
}
func (e *filesystemEntry) Name() string {
@@ -103,7 +92,6 @@ func (fsd *filesystemDirectory) Size() int64 {
type fileWithMetadata struct {
*os.File
options *Options
}
func (f *fileWithMetadata) Entry() (fs.Entry, error) {
@@ -114,7 +102,7 @@ func (f *fileWithMetadata) Entry() (fs.Entry, error) {
basename, prefix := splitDirPrefix(f.Name())
return newFilesystemFile(newEntry(basename, fi, prefix, f.options)), nil
return newFilesystemFile(newEntry(basename, fi, prefix)), nil
}
func (fsf *filesystemFile) Open(_ context.Context) (fs.Reader, error) {
@@ -123,7 +111,7 @@ func (fsf *filesystemFile) Open(_ context.Context) (fs.Reader, error) {
return nil, errors.Wrap(err, "unable to open local file")
}
return &fileWithMetadata{File: f, options: fsf.options}, nil
return &fileWithMetadata{f}, nil
}
func (fsl *filesystemSymlink) Readlink(_ context.Context) (string, error) {
@@ -137,7 +125,7 @@ func (fsl *filesystemSymlink) Resolve(_ context.Context) (fs.Entry, error) {
return nil, errors.Wrapf(err, "cannot resolve symlink for '%q'", fsl.fullPath())
}
return NewEntryWithOptions(target, fsl.options)
return NewEntry(target)
}
func (e *filesystemErrorEntry) ErrorInfo() error {
@@ -157,15 +145,8 @@ func splitDirPrefix(s string) (basename, prefix string) {
}
// Directory returns fs.Directory for the specified path.
// It uses DefaultOptions for configuration.
func Directory(path string) (fs.Directory, error) {
return DirectoryWithOptions(path, DefaultOptions)
}
// DirectoryWithOptions returns fs.Directory for the specified path.
// It uses the provided Options for configuration.
func DirectoryWithOptions(path string, options *Options) (fs.Directory, error) {
e, err := NewEntryWithOptions(path, options)
e, err := NewEntry(path)
if err != nil {
return nil, err
}

View File

@@ -18,7 +18,6 @@
type filesystemDirectoryIterator struct {
dirHandle *os.File
childPrefix string
options *Options
currentIndex int
currentBatch []os.DirEntry
@@ -46,7 +45,7 @@ func (it *filesystemDirectoryIterator) Next(_ context.Context) (fs.Entry, error)
n := it.currentIndex
it.currentIndex++
e, err := toDirEntryOrNil(it.currentBatch[n], it.childPrefix, it.options)
e, err := toDirEntryOrNil(it.currentBatch[n], it.childPrefix)
if err != nil {
// stop iteration
return nil, err
@@ -75,7 +74,7 @@ func (fsd *filesystemDirectory) Iterate(_ context.Context) (fs.DirectoryIterator
childPrefix := fullPath + separatorStr
return &filesystemDirectoryIterator{dirHandle: d, childPrefix: childPrefix, options: fsd.options}, nil
return &filesystemDirectoryIterator{dirHandle: d, childPrefix: childPrefix}, nil
}
func (fsd *filesystemDirectory) Child(_ context.Context, name string) (fs.Entry, error) {
@@ -90,39 +89,47 @@ func (fsd *filesystemDirectory) Child(_ context.Context, name string) (fs.Entry,
return nil, errors.Wrap(err, "unable to get child")
}
return entryFromDirEntry(name, st, fullPath+separatorStr, fsd.options), nil
return entryFromDirEntry(name, st, fullPath+separatorStr), nil
}
func toDirEntryOrNil(dirEntry os.DirEntry, prefix string, options *Options) (fs.Entry, error) {
func toDirEntryOrNil(dirEntry os.DirEntry, prefix string) (fs.Entry, error) {
n := dirEntry.Name()
fi, err := os.Lstat(prefix + n)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
if options != nil && options.IgnoreUnreadableDirEntries {
return nil, nil
switch fi, err := os.Lstat(prefix + n); {
case err == nil:
return entryFromDirEntry(n, fi, prefix), nil
case os.IsNotExist(err):
return nil, nil
case os.IsPermission(err):
// For permission denied errors, return an ErrorEntry instead of failing
// the entire directory iteration. This allows the upload process to
// handle the error according to the configured error handling policy
// and continue processing other entries in the directory.
//
// This is particularly important for inaccessible mount points such as
// FUSE/sshfs mounts owned by another user. If an error is returned here
// then a single inaccessible entry causes the entire containing directory
// to fail and be omitted from the snapshot, which results in omitting
// other accessible entries in the same directory.
e := filesystemEntry{
name: TrimShallowSuffix(n),
size: 0,
mtimeNanos: 0,
mode: dirEntry.Type(),
owner: fs.OwnerInfo{},
device: fs.DeviceInfo{},
prefix: prefix,
}
return newFilesystemErrorEntry(e, err), nil
default:
return nil, errors.Wrap(err, "error reading directory")
}
return entryFromDirEntry(n, fi, prefix, options), nil
}
// NewEntry returns fs.Entry for the specified path, the result will be one of supported entry types: fs.File, fs.Directory, fs.Symlink
// or fs.UnsupportedEntry.
// It uses DefaultOptions for configuration.
func NewEntry(path string) (fs.Entry, error) {
return NewEntryWithOptions(path, DefaultOptions)
}
// NewEntryWithOptions returns fs.Entry for the specified path, the result will be one of supported entry types: fs.File, fs.Directory, fs.Symlink
// or fs.UnsupportedEntry.
// It uses the provided Options for configuration.
func NewEntryWithOptions(path string, options *Options) (fs.Entry, error) {
path = filepath.Clean(path)
fi, err := os.Lstat(path)
@@ -143,42 +150,42 @@ func NewEntryWithOptions(path string, options *Options) (fs.Entry, error) {
}
if path == "/" {
return entryFromDirEntry("/", fi, "", options), nil
return entryFromDirEntry("/", fi, ""), nil
}
basename, prefix := splitDirPrefix(path)
return entryFromDirEntry(basename, fi, prefix, options), nil
return entryFromDirEntry(basename, fi, prefix), nil
}
func entryFromDirEntry(basename string, fi os.FileInfo, prefix string, options *Options) fs.Entry {
func entryFromDirEntry(basename string, fi os.FileInfo, prefix string) fs.Entry {
isplaceholder := strings.HasSuffix(basename, ShallowEntrySuffix)
maskedmode := fi.Mode() & os.ModeType
switch {
case maskedmode == os.ModeDir && !isplaceholder:
return newFilesystemDirectory(newEntry(basename, fi, prefix, options))
return newFilesystemDirectory(newEntry(basename, fi, prefix))
case maskedmode == os.ModeDir && isplaceholder:
return newShallowFilesystemDirectory(newEntry(basename, fi, prefix, options))
return newShallowFilesystemDirectory(newEntry(basename, fi, prefix))
case maskedmode == os.ModeSymlink && !isplaceholder:
return newFilesystemSymlink(newEntry(basename, fi, prefix, options))
return newFilesystemSymlink(newEntry(basename, fi, prefix))
case maskedmode == 0 && !isplaceholder:
return newFilesystemFile(newEntry(basename, fi, prefix, options))
return newFilesystemFile(newEntry(basename, fi, prefix))
case maskedmode == 0 && isplaceholder:
return newShallowFilesystemFile(newEntry(basename, fi, prefix, options))
return newShallowFilesystemFile(newEntry(basename, fi, prefix))
default:
return newFilesystemErrorEntry(newEntry(basename, fi, prefix, options), fs.ErrUnknown)
return newFilesystemErrorEntry(newEntry(basename, fi, prefix), fs.ErrUnknown)
}
}
var _ os.FileInfo = (*filesystemEntry)(nil)
func newEntry(basename string, fi os.FileInfo, prefix string, options *Options) filesystemEntry {
func newEntry(basename string, fi os.FileInfo, prefix string) filesystemEntry {
return filesystemEntry{
TrimShallowSuffix(basename),
fi.Size(),
@@ -187,6 +194,5 @@ func newEntry(basename string, fi os.FileInfo, prefix string, options *Options)
platformSpecificOwnerInfo(fi),
platformSpecificDeviceInfo(fi),
prefix,
options,
}
}

View File

@@ -307,276 +307,45 @@ type pair struct {
}
}
// getOptionsFromEntry extracts the options pointer from an fs.Entry by type assertion.
// Returns nil if the entry doesn't have options or if type assertion fails.
func getOptionsFromEntry(entry fs.Entry) *Options {
switch e := entry.(type) {
case *filesystemDirectory:
return e.options
case *filesystemFile:
return e.options
case *filesystemSymlink:
return e.options
case *filesystemErrorEntry:
return e.options
default:
func TestIteratePermissionDenied(t *testing.T) {
if isWindows {
t.Skip("test not applicable on Windows")
}
if os.Getuid() == 0 {
t.Skip("test cannot run as root")
}
tmp := testutil.TempDirectory(t)
// Create a directory with files, then remove execute permission.
// Without execute permission, the directory can be listed (read)
// but lstat on children will fail with permission denied.
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a"), []byte{1}, 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "b"), []byte{2}, 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "c"), []byte{3}, 0o644))
require.NoError(t, os.Chmod(tmp, 0o644))
t.Cleanup(func() { os.Chmod(tmp, 0o755) })
dir, err := Directory(tmp)
require.NoError(t, err)
ctx := testlogging.Context(t)
var entries []fs.Entry
err = fs.IterateEntries(ctx, dir, func(ctx context.Context, e fs.Entry) error {
entries = append(entries, e)
return nil
})
require.NoError(t, err, "iteration should complete without error")
require.Len(t, entries, 3, "should have 3 entries")
for _, e := range entries {
ee, ok := e.(fs.ErrorEntry)
require.True(t, ok, "entry should be ErrorEntry")
require.True(t, os.IsPermission(ee.ErrorInfo()), "error should be permission denied")
}
}
func TestOptionsPassedToChildEntries(t *testing.T) {
ctx := testlogging.Context(t)
tmp := testutil.TempDirectory(t)
// Create a test directory structure
require.NoError(t, os.WriteFile(filepath.Join(tmp, "file1.txt"), []byte{1, 2, 3}, 0o777))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "file2.txt"), []byte{4, 5, 6}, 0o777))
subdir := filepath.Join(tmp, "subdir")
require.NoError(t, os.Mkdir(subdir, 0o777))
require.NoError(t, os.WriteFile(filepath.Join(subdir, "subfile.txt"), []byte{7, 8, 9}, 0o777))
// Create custom options
customOptions := &Options{
IgnoreUnreadableDirEntries: true,
}
// Create directory with custom options
dir, err := DirectoryWithOptions(tmp, customOptions)
require.NoError(t, err)
// Verify the directory itself has the correct options
dirOptions := getOptionsFromEntry(dir)
require.NotNil(t, dirOptions, "directory should have options")
require.Equal(t, customOptions, dirOptions, "directory should have the same options pointer")
require.True(t, dirOptions.IgnoreUnreadableDirEntries, "directory options should match")
// Test that options are passed to children via Child()
childFile, err := dir.Child(ctx, "file1.txt")
require.NoError(t, err)
childOptions := getOptionsFromEntry(childFile)
require.NotNil(t, childOptions, "child file should have options")
require.Equal(t, customOptions, childOptions, "child file should have the same options pointer")
// Test that options are passed to subdirectories
childDir, err := dir.Child(ctx, "subdir")
require.NoError(t, err)
subdirOptions := getOptionsFromEntry(childDir)
require.NotNil(t, subdirOptions, "subdirectory should have options")
require.Equal(t, customOptions, subdirOptions, "subdirectory should have the same options pointer")
// Test that options are passed to nested children
subdirEntry, ok := childDir.(fs.Directory)
require.True(t, ok, "child directory should be a directory")
nestedFile, err := subdirEntry.Child(ctx, "subfile.txt")
require.NoError(t, err)
nestedOptions := getOptionsFromEntry(nestedFile)
require.NotNil(t, nestedOptions, "nested file should have options")
require.Equal(t, customOptions, nestedOptions, "nested file should have the same options pointer")
}
func TestOptionsPassedThroughIteration(t *testing.T) {
ctx := testlogging.Context(t)
tmp := testutil.TempDirectory(t)
// Create a test directory structure
require.NoError(t, os.WriteFile(filepath.Join(tmp, "file1.txt"), []byte{1, 2, 3}, 0o777))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "file2.txt"), []byte{4, 5, 6}, 0o777))
require.NoError(t, os.Mkdir(filepath.Join(tmp, "subdir"), 0o777))
// Create custom options
customOptions := &Options{
IgnoreUnreadableDirEntries: true,
}
// Create directory with custom options
dir, err := DirectoryWithOptions(tmp, customOptions)
require.NoError(t, err)
// Iterate through entries and verify all have the same options pointer
iter, err := dir.Iterate(ctx)
require.NoError(t, err)
defer iter.Close()
entryCount := 0
for {
entry, err := iter.Next(ctx)
if err != nil {
t.Fatalf("iteration error: %v", err)
}
if entry == nil {
break
}
entryCount++
entryOptions := getOptionsFromEntry(entry)
require.NotNil(t, entryOptions, "entry %s should have options", entry.Name())
require.Equal(t, customOptions, entryOptions, "entry %s should have the same options pointer", entry.Name())
}
require.Equal(t, 3, entryCount, "should have found 3 entries")
}
func TestOptionsPassedThroughSymlinkResolution(t *testing.T) {
ctx := testlogging.Context(t)
tmp := testutil.TempDirectory(t)
// Create a target file
targetFile := filepath.Join(tmp, "target.txt")
require.NoError(t, os.WriteFile(targetFile, []byte{1, 2, 3}, 0o777))
// Create a symlink
symlinkPath := filepath.Join(tmp, "link")
require.NoError(t, os.Symlink(targetFile, symlinkPath))
// Create custom options
customOptions := &Options{
IgnoreUnreadableDirEntries: true,
}
// Create symlink entry with custom options
symlinkEntry, err := NewEntryWithOptions(symlinkPath, customOptions)
require.NoError(t, err)
// Verify the symlink has the correct options
symlinkOptions := getOptionsFromEntry(symlinkEntry)
require.NotNil(t, symlinkOptions, "symlink should have options")
require.Equal(t, customOptions, symlinkOptions, "symlink should have the same options pointer")
// Resolve the symlink and verify the resolved entry has the same options
symlink, ok := symlinkEntry.(fs.Symlink)
require.True(t, ok, "entry should be a symlink")
resolved, err := symlink.Resolve(ctx)
require.NoError(t, err)
resolvedOptions := getOptionsFromEntry(resolved)
require.NotNil(t, resolvedOptions, "resolved entry should have options")
require.Equal(t, customOptions, resolvedOptions, "resolved entry should have the same options pointer")
}
func TestOptionsPassedToNewEntry(t *testing.T) {
tmp := testutil.TempDirectory(t)
// Create a file
filePath := filepath.Join(tmp, "testfile.txt")
require.NoError(t, os.WriteFile(filePath, []byte{1, 2, 3}, 0o777))
// Create custom options
customOptions := &Options{
IgnoreUnreadableDirEntries: true,
}
// Create entry with custom options
entry, err := NewEntryWithOptions(filePath, customOptions)
require.NoError(t, err)
// Verify the entry has the correct options
entryOptions := getOptionsFromEntry(entry)
require.NotNil(t, entryOptions, "entry should have options")
require.Equal(t, customOptions, entryOptions, "entry should have the same options pointer")
}
func TestOptionsPassedToNestedDirectories(t *testing.T) {
ctx := testlogging.Context(t)
tmp := testutil.TempDirectory(t)
// Create nested directory structure
level1 := filepath.Join(tmp, "level1")
level2 := filepath.Join(level1, "level2")
level3 := filepath.Join(level2, "level3")
require.NoError(t, os.MkdirAll(level3, 0o777))
require.NoError(t, os.WriteFile(filepath.Join(level3, "file.txt"), []byte{1, 2, 3}, 0o777))
// Create custom options
customOptions := &Options{
IgnoreUnreadableDirEntries: true,
}
// Create root directory with custom options
rootDir, err := DirectoryWithOptions(tmp, customOptions)
require.NoError(t, err)
// Navigate through nested directories and verify options are passed
currentDir := rootDir
levels := []string{"level1", "level2", "level3"}
for _, level := range levels {
child, err := currentDir.Child(ctx, level)
require.NoError(t, err)
childOptions := getOptionsFromEntry(child)
require.NotNil(t, childOptions, "directory %s should have options", level)
require.Equal(t, customOptions, childOptions, "directory %s should have the same options pointer", level)
var ok bool
currentDir, ok = child.(fs.Directory)
require.True(t, ok, "child should be a directory")
}
// Verify the file in the deepest directory has the same options
file, err := currentDir.Child(ctx, "file.txt")
require.NoError(t, err)
fileOptions := getOptionsFromEntry(file)
require.NotNil(t, fileOptions, "file should have options")
require.Equal(t, customOptions, fileOptions, "file should have the same options pointer")
}
func TestDefaultOptionsUsedByDefault(t *testing.T) {
tmp := testutil.TempDirectory(t)
// Create a file
filePath := filepath.Join(tmp, "testfile.txt")
require.NoError(t, os.WriteFile(filePath, []byte{1, 2, 3}, 0o777))
// Use default NewEntry (should use DefaultOptions)
entry, err := NewEntry(filePath)
require.NoError(t, err)
// Verify the entry has DefaultOptions
entryOptions := getOptionsFromEntry(entry)
require.NotNil(t, entryOptions, "entry should have options")
require.Equal(t, DefaultOptions, entryOptions, "entry should have DefaultOptions pointer")
}
func TestDifferentOptionsInstances(t *testing.T) {
tmp := testutil.TempDirectory(t)
// Create two different files
filePath1 := filepath.Join(tmp, "testfile1.txt")
filePath2 := filepath.Join(tmp, "testfile2.txt")
require.NoError(t, os.WriteFile(filePath1, []byte{1, 2, 3}, 0o777))
require.NoError(t, os.WriteFile(filePath2, []byte{4, 5, 6}, 0o777))
// Create two different options instances with same values
options1 := &Options{IgnoreUnreadableDirEntries: true}
options2 := &Options{IgnoreUnreadableDirEntries: false}
// Create entries with different options instances
entry1, err := NewEntryWithOptions(filePath1, options1)
require.NoError(t, err)
entry2, err := NewEntryWithOptions(filePath2, options2)
require.NoError(t, err)
// Verify they have the correct options pointers
entry1Options := getOptionsFromEntry(entry1)
entry2Options := getOptionsFromEntry(entry2)
require.NotNil(t, entry1Options)
require.NotNil(t, entry2Options)
require.Equal(t, options1, entry1Options, "entry1 should have options1 pointer")
require.Equal(t, options2, entry2Options, "entry2 should have options2 pointer")
require.NotEqual(t, entry1Options, entry2Options, "entries should have different options pointers")
require.True(t, entry1Options.IgnoreUnreadableDirEntries, "entry1 options should have IgnoreUnreadableDirEntries=true")
require.False(t, entry2Options.IgnoreUnreadableDirEntries, "entry2 options should have IgnoreUnreadableDirEntries=false")
}

View File

@@ -831,7 +831,7 @@ func (u *Uploader) processDirectoryEntries(
return nil
}
//nolint:funlen
//nolint:funlen,gocyclo
func (u *Uploader) processSingle(
ctx context.Context,
entry fs.Entry,
@@ -938,8 +938,14 @@ func (u *Uploader) processSingle(
prefix = "unknown entry"
} else {
isIgnoredError = policyTree.EffectivePolicy().ErrorHandlingPolicy.IgnoreFileErrors.OrDefault(false)
prefix = "error"
ehp := policyTree.EffectivePolicy().ErrorHandlingPolicy
if entry.IsDir() {
isIgnoredError = ehp.IgnoreDirectoryErrors.OrDefault(false)
} else {
isIgnoredError = ehp.IgnoreFileErrors.OrDefault(false)
}
}
return u.processEntryUploadResult(ctx, nil, entry.ErrorInfo(), entryRelativePath, parentDirBuilder,

View File

@@ -157,6 +157,13 @@ func testSnapshotFailCases(
wantIgnoredErrors: cond(ignoringDirs, 1, 0),
wantPartial: !ignoringDirs && isFailFast,
}
expectedWhenUnreadableDirEntries = expectedSnapshotResult{
success: ignoringFiles && ignoringDirs,
wantErrors: fatalErrorCount(ignoringDirs, ignoringFiles),
wantIgnoredErrors: ignoredErrorCount(ignoringDirs, ignoringFiles),
wantPartial: !(ignoringFiles && ignoringDirs) && isFailFast, //nolint:staticcheck
}
)
// Test the root dir permissions
@@ -213,7 +220,7 @@ func testSnapshotFailCases(
expectSuccess: map[os.FileMode]expectedSnapshotResult{
0o000: expectEarlyFailure,
0o100: expectEarlyFailure,
0o400: expectEarlyFailure,
0o400: expectedWhenUnreadableDirEntries,
},
},
{
@@ -243,7 +250,7 @@ func testSnapshotFailCases(
expectSuccess: map[os.FileMode]expectedSnapshotResult{
0o000: expectedWhenIgnoringDirs,
0o100: expectedWhenIgnoringDirs,
0o400: expectedWhenIgnoringDirs,
0o400: expectedWhenUnreadableDirEntries,
},
},
{
@@ -273,7 +280,7 @@ func testSnapshotFailCases(
expectSuccess: map[os.FileMode]expectedSnapshotResult{
0o000: expectedWhenIgnoringDirs,
0o100: expectedWhenIgnoringDirs,
0o400: expectedWhenIgnoringDirs,
0o400: expectedWhenUnreadableDirEntries,
},
},
{
@@ -295,6 +302,24 @@ func testSnapshotFailCases(
}
}
func ignoredErrorCount(ignoringDirErrs, ignoringFileErrs bool) int {
var errCount int
if ignoringDirErrs {
errCount += 2
}
if ignoringFileErrs {
errCount += 1
}
return errCount
}
func fatalErrorCount(ignoringDirErrs, ignoringFileErrs bool) int {
return 3 - ignoredErrorCount(ignoringDirErrs, ignoringFileErrs)
}
func createSimplestFileTree(t *testing.T, maxDirDepth, currDepth int, currPath string) {
t.Helper()
@@ -397,9 +422,16 @@ func testPermissions(
e.RunAndExpectSuccess(t, "snapshot", "restore", parsed.manifestID, target)
}
require.Equal(t, expected.wantErrors, parsed.errorCount, "unexpected number of errors")
require.Equal(t, expected.wantIgnoredErrors, parsed.ignoredErrorCount, "unexpected number of ignored errors")
require.Equal(t, expected.wantPartial, parsed.partial, "unexpected partial")
if expected.wantPartial {
// for partial snapshots, only check that at least one fatal error was recorded
require.Positive(t, parsed.errorCount, "expected at least one fatal error")
} else {
// the total number of errors can only be validated for non-partial snapshots
require.Equal(t, expected.wantErrors, parsed.errorCount, "unexpected number of errors")
require.Equal(t, expected.wantIgnoredErrors, parsed.ignoredErrorCount, "unexpected number of ignored errors")
}
})
}
}