Add ability to scan snaps (as a source) (#3929)

This commit is contained in:
Alex Goodman
2025-06-25 16:53:35 -04:00
committed by GitHub
parent 4eb8ba4575
commit 2bda086423
63 changed files with 5060 additions and 175 deletions
+9 -8
View File
@@ -14,7 +14,7 @@ var _ file.Resolver = (*Directory)(nil)
// Directory implements path and content access for the directory data source.
type Directory struct {
filetreeResolver
FiletreeResolver
path string
indexer *directoryIndexer
}
@@ -39,10 +39,11 @@ func newFromDirectoryWithoutIndex(root string, base string, pathFilters ...PathI
return &Directory{
path: cleanRoot,
filetreeResolver: filetreeResolver{
chroot: *chroot,
tree: filetree.New(),
index: filetree.NewIndex(),
FiletreeResolver: FiletreeResolver{
Chroot: *chroot,
Tree: filetree.New(),
Index: filetree.NewIndex(),
Opener: nativeOSFileOpener,
},
indexer: newDirectoryIndexer(cleanRoot, cleanBase, pathFilters...),
}, nil
@@ -57,9 +58,9 @@ func (r *Directory) buildIndex() error {
return err
}
r.tree = tree
r.index = index
r.searchContext = filetree.NewSearchContext(tree, index)
r.Tree = tree
r.Index = index
r.SearchContext = filetree.NewSearchContext(tree, index)
return nil
}
@@ -9,14 +9,12 @@ import (
"path/filepath"
"strings"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/internal/windows"
)
@@ -64,14 +62,14 @@ func (r *directoryIndexer) build() (filetree.Reader, filetree.IndexReader, error
return r.tree, r.index, indexAllRoots(r.path, r.indexTree)
}
func indexAllRoots(root string, indexer func(string, *progress.Stage) ([]string, error)) error {
func indexAllRoots(root string, indexer func(string, *progress.AtomicStage) ([]string, error)) error {
// why account for multiple roots? To cover cases when there is a symlink that references above the root path,
// in which case we need to additionally index where the link resolves to. it's for this reason why the filetree
// must be relative to the root of the filesystem (and not just relative to the given path).
pathsToIndex := []string{root}
fullPathsMap := map[string]struct{}{}
stager, prog := indexingProgress(root)
prog := bus.StartIndexingFiles(root)
defer prog.SetCompleted()
loop:
for {
@@ -85,7 +83,7 @@ loop:
currentPath, pathsToIndex = pathsToIndex[0], pathsToIndex[1:]
}
additionalRoots, err := indexer(currentPath, stager)
additionalRoots, err := indexer(currentPath, prog.AtomicStage)
if err != nil {
return fmt.Errorf("unable to index filesystem path=%q: %w", currentPath, err)
}
@@ -101,7 +99,7 @@ loop:
return nil
}
func (r *directoryIndexer) indexTree(root string, stager *progress.Stage) ([]string, error) {
func (r *directoryIndexer) indexTree(root string, stager *progress.AtomicStage) ([]string, error) {
log.WithFields("path", root).Trace("indexing filetree")
var roots []string
@@ -144,7 +142,7 @@ func (r *directoryIndexer) indexTree(root string, stager *progress.Stage) ([]str
err = filepath.Walk(root,
func(path string, info os.FileInfo, err error) error {
stager.Current = path
stager.Set(path)
newRoot, err := r.indexPath(path, info, err)
@@ -179,7 +177,7 @@ func isRealPath(root string) (bool, error) {
return rootParent == realRootParent, nil
}
func (r *directoryIndexer) indexBranch(root string, stager *progress.Stage) ([]string, error) {
func (r *directoryIndexer) indexBranch(root string, stager *progress.AtomicStage) ([]string, error) {
rootRealPath, err := filepath.EvalSymlinks(root)
if err != nil {
var pathErr *os.PathError
@@ -213,7 +211,7 @@ func (r *directoryIndexer) indexBranch(root string, stager *progress.Stage) ([]s
targetPath = p
}
stager.Current = targetPath
stager.Set(targetPath)
lstat, err := os.Lstat(targetPath)
newRoot, err := r.indexPath(targetPath, lstat, err)
@@ -492,22 +490,3 @@ func requireFileInfo(_, _ string, info os.FileInfo, _ error) error {
}
return nil
}
func indexingProgress(path string) (*progress.Stage, *progress.Manual) {
stage := &progress.Stage{}
prog := progress.NewManual(-1)
bus.Publish(partybus.Event{
Type: event.FileIndexingStarted,
Source: path,
Value: struct {
progress.Stager
progress.Progressable
}{
Stager: progress.Stager(stage),
Progressable: prog,
},
})
return stage, prog
}
@@ -25,7 +25,7 @@ type indexerMock struct {
additionalRoots map[string][]string
}
func (m *indexerMock) indexer(s string, _ *progress.Stage) ([]string, error) {
func (m *indexerMock) indexer(s string, _ *progress.AtomicStage) ([]string, error) {
m.observedRoots = append(m.observedRoots, s)
return m.additionalRoots[s], nil
}
+9 -8
View File
@@ -12,7 +12,7 @@ var _ file.Resolver = (*File)(nil)
// File implements path and content access for the file data source.
type File struct {
filetreeResolver
FiletreeResolver
path string
indexer *fileIndexer
}
@@ -29,10 +29,11 @@ func NewFromFile(parent, path string, pathFilters ...PathIndexVisitor) (*File, e
file := &File{
path: path,
filetreeResolver: filetreeResolver{
chroot: *chroot,
tree: filetree.New(),
index: filetree.NewIndex(),
FiletreeResolver: FiletreeResolver{
Chroot: *chroot,
Tree: filetree.New(),
Index: filetree.NewIndex(),
Opener: nativeOSFileOpener,
},
indexer: newFileIndexer(path, cleanBase, pathFilters...),
}
@@ -49,9 +50,9 @@ func (r *File) buildIndex() error {
return err
}
r.tree = tree
r.index = index
r.searchContext = filetree.NewSearchContext(tree, index)
r.Tree = tree
r.Index = index
r.SearchContext = filetree.NewSearchContext(tree, index)
return nil
}
+7 -6
View File
@@ -9,6 +9,7 @@ import (
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/internal/windows"
)
@@ -49,14 +50,14 @@ func (r *fileIndexer) build() (filetree.Reader, filetree.IndexReader, error) {
// Index file at the given path
// A file indexer simply indexes the file and its directory.
func index(path string, indexer func(string, *progress.Stage) error) error {
func index(path string, indexer func(string, *progress.AtomicStage) error) error {
// We want to index the file at the provided path and its parent directory.
// We need to probably check that we have file access
// We also need to determine what to do when the file itself is a symlink.
stager, prog := indexingProgress(path)
prog := bus.StartIndexingFiles(path)
defer prog.SetCompleted()
err := indexer(path, stager)
err := indexer(path, prog.AtomicStage)
if err != nil {
return fmt.Errorf("unable to index filesystem path=%q: %w", path, err)
}
@@ -70,7 +71,7 @@ func index(path string, indexer func(string, *progress.Stage) error) error {
// permissions errors on the file at path or its parent directory will return an error.
// Filter functions provided to the indexer are honoured, so if the path provided (or its parent
// directory) is filtered by a filter function, an error is returned.
func (r *fileIndexer) indexPath(path string, stager *progress.Stage) error {
func (r *fileIndexer) indexPath(path string, stager *progress.AtomicStage) error {
log.WithFields("path", path).Trace("indexing file path")
absPath, err := filepath.Abs(path)
@@ -105,14 +106,14 @@ func (r *fileIndexer) indexPath(path string, stager *progress.Stage) error {
return fmt.Errorf("unable to stat parent of file=%q: %w", absSymlinkFreeParent, err)
}
stager.Current = absSymlinkFreeParent
stager.Set(absSymlinkFreeParent)
indexParentErr := r.filterAndIndex(absSymlinkFreeParent, parentFi)
if indexParentErr != nil {
return indexParentErr
}
// We have indexed the parent successfully, now attempt to index the file.
stager.Current = absSymlinkFreeFilePath
stager.Set(absSymlinkFreeFilePath)
indexFileErr := r.filterAndIndex(absSymlinkFreeFilePath, fi)
if indexFileErr != nil {
return indexFileErr
+42 -36
View File
@@ -14,33 +14,46 @@ import (
"github.com/anchore/syft/syft/internal/windows"
)
type filetreeResolver struct {
chroot ChrootContext
tree filetree.Reader
index filetree.IndexReader
searchContext filetree.Searcher
// TODO: consider making a constructor for this
type FiletreeResolver struct {
Chroot ChrootContext
Tree filetree.Reader
Index filetree.IndexReader
SearchContext filetree.Searcher
Opener func(stereoscopeFile.Reference) (io.ReadCloser, error)
}
func (r *filetreeResolver) requestPath(userPath string) (string, error) {
return r.chroot.ToNativePath(userPath)
func nativeOSFileOpener(ref stereoscopeFile.Reference) (io.ReadCloser, error) {
// RealPath is posix so for windows file resolver we need to translate
// to its true on disk path.
filePath := string(ref.RealPath)
if windows.HostRunningOnWindows() {
filePath = windows.FromPosix(filePath)
}
return stereoscopeFile.NewLazyReadCloser(filePath), nil
}
func (r *FiletreeResolver) requestPath(userPath string) (string, error) {
return r.Chroot.ToNativePath(userPath)
}
// responsePath takes a path from the underlying fs domain and converts it to a path that is relative to the root of the file resolver.
func (r filetreeResolver) responsePath(path string) string {
return r.chroot.ToChrootPath(path)
func (r FiletreeResolver) responsePath(path string) string {
return r.Chroot.ToChrootPath(path)
}
// HasPath indicates if the given path exists in the underlying source.
func (r *filetreeResolver) HasPath(userPath string) bool {
func (r *FiletreeResolver) HasPath(userPath string) bool {
requestPath, err := r.requestPath(userPath)
if err != nil {
return false
}
return r.tree.HasPath(stereoscopeFile.Path(requestPath))
return r.Tree.HasPath(stereoscopeFile.Path(requestPath))
}
// FilesByPath returns all file.References that match the given paths from the file index.
func (r filetreeResolver) FilesByPath(userPaths ...string) ([]file.Location, error) {
func (r FiletreeResolver) FilesByPath(userPaths ...string) ([]file.Location, error) {
var references = make([]file.Location, 0)
for _, userPath := range userPaths {
@@ -51,7 +64,7 @@ func (r filetreeResolver) FilesByPath(userPaths ...string) ([]file.Location, err
}
// we should be resolving symlinks and preserving this information as a AccessPath to the real file
ref, err := r.searchContext.SearchByPath(userStrPath, filetree.FollowBasenameLinks)
ref, err := r.SearchContext.SearchByPath(userStrPath, filetree.FollowBasenameLinks)
if err != nil {
log.Tracef("unable to evaluate symlink for path=%q : %+v", userPath, err)
continue
@@ -61,7 +74,7 @@ func (r filetreeResolver) FilesByPath(userPaths ...string) ([]file.Location, err
continue
}
entry, err := r.index.Get(*ref.Reference)
entry, err := r.Index.Get(*ref.Reference)
if err != nil {
log.Warnf("unable to get file by path=%q : %+v", userPath, err)
continue
@@ -90,12 +103,12 @@ func (r filetreeResolver) FilesByPath(userPaths ...string) ([]file.Location, err
return references, nil
}
func (r filetreeResolver) requestGlob(pattern string) (string, error) {
return r.chroot.ToNativeGlob(pattern)
func (r FiletreeResolver) requestGlob(pattern string) (string, error) {
return r.Chroot.ToNativeGlob(pattern)
}
// FilesByGlob returns all file.References that match the given path glob pattern from any layer in the image.
func (r filetreeResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
func (r FiletreeResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
uniqueFileIDs := stereoscopeFile.NewFileReferenceSet()
uniqueLocations := make([]file.Location, 0)
@@ -104,7 +117,7 @@ func (r filetreeResolver) FilesByGlob(patterns ...string) ([]file.Location, erro
if err != nil {
return nil, err
}
refVias, err := r.searchContext.SearchByGlob(requestGlob, filetree.FollowBasenameLinks)
refVias, err := r.SearchContext.SearchByGlob(requestGlob, filetree.FollowBasenameLinks)
if err != nil {
return nil, err
}
@@ -112,7 +125,7 @@ func (r filetreeResolver) FilesByGlob(patterns ...string) ([]file.Location, erro
if !refVia.HasReference() || uniqueFileIDs.Contains(*refVia.Reference) {
continue
}
entry, err := r.index.Get(*refVia.Reference)
entry, err := r.Index.Get(*refVia.Reference)
if err != nil {
return nil, fmt.Errorf("unable to get file metadata for reference %s: %w", refVia.RealPath, err)
}
@@ -137,7 +150,7 @@ func (r filetreeResolver) FilesByGlob(patterns ...string) ([]file.Location, erro
// RelativeFileByPath fetches a single file at the given path relative to the layer squash of the given reference.
// This is helpful when attempting to find a file that is in the same layer or lower as another file.
func (r *filetreeResolver) RelativeFileByPath(_ file.Location, path string) *file.Location {
func (r *FiletreeResolver) RelativeFileByPath(_ file.Location, path string) *file.Location {
paths, err := r.FilesByPath(path)
if err != nil {
return nil
@@ -151,12 +164,12 @@ func (r *filetreeResolver) RelativeFileByPath(_ file.Location, path string) *fil
// FileContentsByLocation fetches file contents for a single file reference relative to a directory.
// If the path does not exist an error is returned.
func (r filetreeResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
func (r FiletreeResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
if location.RealPath == "" {
return nil, errors.New("empty path given")
}
entry, err := r.index.Get(location.Reference())
entry, err := r.Index.Get(location.Reference())
if err != nil {
return nil, err
}
@@ -166,21 +179,14 @@ func (r filetreeResolver) FileContentsByLocation(location file.Location) (io.Rea
return nil, fmt.Errorf("cannot read contents of non-file %q", location.Reference().RealPath)
}
// RealPath is posix so for windows file resolver we need to translate
// to its true on disk path.
filePath := string(location.Reference().RealPath)
if windows.HostRunningOnWindows() {
filePath = windows.FromPosix(filePath)
}
return stereoscopeFile.NewLazyReadCloser(filePath), nil
return r.Opener(location.Reference())
}
func (r *filetreeResolver) AllLocations(ctx context.Context) <-chan file.Location {
func (r *FiletreeResolver) AllLocations(ctx context.Context) <-chan file.Location {
results := make(chan file.Location)
go func() {
defer close(results)
for _, ref := range r.tree.AllFiles(stereoscopeFile.AllTypes()...) {
for _, ref := range r.Tree.AllFiles(stereoscopeFile.AllTypes()...) {
select {
case <-ctx.Done():
return
@@ -192,8 +198,8 @@ func (r *filetreeResolver) AllLocations(ctx context.Context) <-chan file.Locatio
return results
}
func (r *filetreeResolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
entry, err := r.index.Get(location.Reference())
func (r *FiletreeResolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
entry, err := r.Index.Get(location.Reference())
if err != nil {
return file.Metadata{}, fmt.Errorf("location: %+v : %w", location, os.ErrNotExist)
}
@@ -201,11 +207,11 @@ func (r *filetreeResolver) FileMetadataByLocation(location file.Location) (file.
return entry.Metadata, nil
}
func (r *filetreeResolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
func (r *FiletreeResolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
uniqueFileIDs := stereoscopeFile.NewFileReferenceSet()
uniqueLocations := make([]file.Location, 0)
refVias, err := r.searchContext.SearchByMIMEType(types...)
refVias, err := r.SearchContext.SearchByMIMEType(types...)
if err != nil {
return nil, err
}
@@ -971,7 +971,7 @@ func Test_directoryResolver_FileContentsByLocation(t *testing.T) {
r, err := NewFromDirectory(".", "")
require.NoError(t, err)
exists, existingPath, err := r.tree.File(stereoscopeFile.Path(filepath.Join(cwd, "test-fixtures/image-simple/file-1.txt")))
exists, existingPath, err := r.Tree.File(stereoscopeFile.Path(filepath.Join(cwd, "test-fixtures/image-simple/file-1.txt")))
require.True(t, exists)
require.NoError(t, err)
require.True(t, existingPath.HasReference())
@@ -1271,7 +1271,7 @@ func TestDirectoryResolver_FilesContents_errorOnDirRequest(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for loc := range resolver.AllLocations(ctx) {
entry, err := resolver.index.Get(loc.Reference())
entry, err := resolver.Index.Get(loc.Reference())
require.NoError(t, err)
if entry.Metadata.IsDir() {
dirLoc = &loc
@@ -1500,7 +1500,7 @@ func Test_fileResolver_FileContentsByLocation(t *testing.T) {
r, err := NewFromFile(parentPath, filePath)
require.NoError(t, err)
exists, existingPath, err := r.tree.File(stereoscopeFile.Path(filepath.Join(cwd, "test-fixtures/image-simple/file-1.txt")))
exists, existingPath, err := r.Tree.File(stereoscopeFile.Path(filepath.Join(cwd, "test-fixtures/image-simple/file-1.txt")))
require.True(t, exists)
require.NoError(t, err)
require.True(t, existingPath.HasReference())
@@ -1553,7 +1553,7 @@ func TestFileResolver_AllLocations_errorOnDirRequest(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for loc := range resolver.AllLocations(ctx) {
entry, err := resolver.index.Get(loc.Reference())
entry, err := resolver.Index.Get(loc.Reference())
require.NoError(t, err)
if dirLoc == nil && entry.Metadata.IsDir() {
dirLoc = &loc
+1 -1
View File
@@ -6,5 +6,5 @@ import "github.com/anchore/syft/syft/source"
// AllTypes returns a list of all source metadata types that syft supports (that are represented in the source.Description.Metadata field).
func AllTypes() []any {
return []any{source.DirectoryMetadata{}, source.FileMetadata{}, source.ImageMetadata{}}
return []any{source.DirectoryMetadata{}, source.FileMetadata{}, source.ImageMetadata{}, source.SnapMetadata{}}
}
+1
View File
@@ -11,6 +11,7 @@ var jsonNameFromType = map[reflect.Type][]string{
reflect.TypeOf(source.DirectoryMetadata{}): {"directory", "dir"},
reflect.TypeOf(source.FileMetadata{}): {"file"},
reflect.TypeOf(source.ImageMetadata{}): {"image"},
reflect.TypeOf(source.SnapMetadata{}): {"snap"},
}
func AllTypeNames() []string {
+67 -6
View File
@@ -4,6 +4,9 @@ import (
"bytes"
"fmt"
"io"
"sync"
"github.com/diskfs/go-diskfs/filesystem/squashfs"
macho "github.com/anchore/go-macholibre"
"github.com/anchore/syft/internal/log"
@@ -48,12 +51,15 @@ func GetUnionReader(readerCloser io.ReadCloser) (UnionReader, error) {
// file.LocationReadCloser embeds a ReadCloser, which is likely
// to implement UnionReader. Check whether the embedded read closer
// implements UnionReader, and just return that if so.
r, ok := readerCloser.(file.LocationReadCloser)
if ok {
ur, ok := r.ReadCloser.(UnionReader)
if ok {
return ur, nil
}
if r, ok := readerCloser.(file.LocationReadCloser); ok {
return GetUnionReader(r.ReadCloser)
}
if r, ok := readerCloser.(*squashfs.File); ok {
// seeking is implemented, but not io.ReaderAt. Lets wrap it to prevent from degrading performance
// by copying all data.
return newReaderAtAdapter(r), nil
}
b, err := io.ReadAll(readerCloser)
@@ -75,3 +81,58 @@ func GetUnionReader(readerCloser io.ReadCloser) (UnionReader, error) {
return reader, nil
}
type readerAtAdapter struct {
io.ReadSeekCloser
mu *sync.Mutex
}
func newReaderAtAdapter(rs io.ReadSeekCloser) UnionReader {
return &readerAtAdapter{
ReadSeekCloser: rs,
mu: &sync.Mutex{},
}
}
func (r *readerAtAdapter) Read(p []byte) (n int, err error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.ReadSeekCloser.Read(p)
}
func (r *readerAtAdapter) Seek(offset int64, whence int) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.ReadSeekCloser.Seek(offset, whence)
}
func (r *readerAtAdapter) ReadAt(p []byte, off int64) (n int, err error) {
r.mu.Lock()
defer r.mu.Unlock()
currentPos, err := r.ReadSeekCloser.Seek(0, io.SeekCurrent) // save current pos
if err != nil {
return 0, err
}
_, err = r.ReadSeekCloser.Seek(off, io.SeekStart) // seek to absolute position `off`
if err != nil {
return 0, err
}
n, err = r.ReadSeekCloser.Read(p) // read from that absolute position
// restore the position for the stateful read/seek operations
if restoreErr := r.restorePosition(currentPos); restoreErr != nil {
if err == nil {
err = restoreErr
}
}
return n, err
}
func (r *readerAtAdapter) restorePosition(pos int64) error {
_, err := r.ReadSeekCloser.Seek(pos, io.SeekStart)
return err
}
@@ -1,8 +1,10 @@
package unionreader
import (
"bytes"
"io"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
@@ -61,3 +63,275 @@ func Test_getUnionReader_fileLocationReadCloser(t *testing.T) {
require.NoError(t, err)
require.Equal(t, p, ur)
}
func TestReaderAtAdapter_ReadAt(t *testing.T) {
testData := "Hello, World! This is a test string for ReadAt."
t.Run("basic functionality", func(t *testing.T) {
reader := newReadSeekCloser(strings.NewReader(testData))
adapter := newReaderAtAdapter(reader)
tests := []struct {
name string
offset int64
length int
expected string
}{
{name: "read from beginning", offset: 0, length: 5, expected: "Hello"},
{name: "read from middle", offset: 7, length: 5, expected: "World"},
{name: "read from end", offset: int64(len(testData) - 4), length: 4, expected: "dAt."},
{name: "read single character", offset: 12, length: 1, expected: "!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := make([]byte, tt.length)
n, err := adapter.ReadAt(buf, tt.offset)
if err != nil && err != io.EOF {
t.Fatalf("Unexpected error: %v", err)
}
result := string(buf[:n])
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
})
t.Run("edge cases", func(t *testing.T) {
tests := []struct {
name string
data string
offset int64
bufSize int
expectedN int
expectedErr error
expectedStr string
}{
{
name: "beyond EOF",
data: "Hello",
offset: 10,
bufSize: 5,
expectedN: 0,
expectedErr: io.EOF,
expectedStr: "",
},
{
name: "partial read",
data: "Hello",
offset: 2,
bufSize: 10,
expectedN: 3,
expectedErr: nil,
expectedStr: "llo",
},
{
name: "empty buffer",
data: "Hello",
offset: 0,
bufSize: 0,
expectedN: 0,
expectedErr: nil,
expectedStr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := newReadSeekCloser(strings.NewReader(tt.data))
adapter := newReaderAtAdapter(reader)
buf := make([]byte, tt.bufSize)
n, err := adapter.ReadAt(buf, tt.offset)
if err != tt.expectedErr {
t.Errorf("Expected error %v, got %v", tt.expectedErr, err)
}
if n != tt.expectedN {
t.Errorf("Expected %d bytes read, got %d", tt.expectedN, n)
}
result := string(buf[:n])
if result != tt.expectedStr {
t.Errorf("Expected %q, got %q", tt.expectedStr, result)
}
})
}
})
t.Run("multiple reads from same position", func(t *testing.T) {
reader := newReadSeekCloser(strings.NewReader(testData))
adapter := newReaderAtAdapter(reader)
// read the same data multiple times
for i := 0; i < 3; i++ {
buf := make([]byte, 5)
n, err := adapter.ReadAt(buf, 7)
if err != nil && err != io.EOF {
t.Fatalf("ReadAt %d failed: %v", i, err)
}
result := string(buf[:n])
if result != "World" {
t.Errorf("ReadAt %d: expected 'World', got %q", i, result)
}
}
})
t.Run("concurrent access", func(t *testing.T) {
td := "0123456789abcdefghijklmnopqrstuvwxyz"
reader := newReadSeekCloser(strings.NewReader(td))
adapter := newReaderAtAdapter(reader)
const numGoroutines = 10
const numReads = 100
var wg sync.WaitGroup
results := make(chan bool, numGoroutines*numReads)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < numReads; j++ {
offset := int64(goroutineID % len(td))
buf := make([]byte, 1)
n, err := adapter.ReadAt(buf, offset)
if err != nil && err != io.EOF {
results <- false
return
}
if n > 0 {
expected := td[offset]
if buf[0] != expected {
results <- false
return
}
}
results <- true
}
}(i)
}
wg.Wait()
close(results)
successCount := 0
totalCount := 0
for success := range results {
totalCount++
if success {
successCount++
}
}
if successCount != totalCount {
t.Errorf("Concurrent reads failed: %d/%d successful", successCount, totalCount)
}
})
}
func TestReaderAtAdapter_PositionHandling(t *testing.T) {
testData := "Hello, World!"
t.Run("preserves position after ReadAt", func(t *testing.T) {
reader := newReadSeekCloser(strings.NewReader(testData))
adapter := newReaderAtAdapter(reader)
// move to a specific position
initialPos := int64(7)
_, err := adapter.Seek(initialPos, io.SeekStart)
if err != nil {
t.Fatalf("Failed to seek: %v", err)
}
// read using ReadAt
buf := make([]byte, 5)
_, err = adapter.ReadAt(buf, 0)
if err != nil && err != io.EOF {
t.Fatalf("ReadAt failed: %v", err)
}
// verify position is preserved
currentPos, err := adapter.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatalf("Failed to get current position: %v", err)
}
if currentPos != initialPos {
t.Errorf("Position not preserved. Expected %d, got %d", initialPos, currentPos)
}
})
t.Run("does not affect regular reads", func(t *testing.T) {
reader := newReadSeekCloser(strings.NewReader(testData))
adapter := newReaderAtAdapter(reader)
// read first few bytes normally
normalBuf := make([]byte, 5)
n, err := adapter.Read(normalBuf)
if err != nil {
t.Fatalf("Normal read failed: %v", err)
}
if string(normalBuf[:n]) != "Hello" {
t.Errorf("Expected 'Hello', got %q", string(normalBuf[:n]))
}
// use ReadAt to read from a different position
readAtBuf := make([]byte, 5)
n, err = adapter.ReadAt(readAtBuf, 7)
if err != nil && err != io.EOF {
t.Fatalf("ReadAt failed: %v", err)
}
if string(readAtBuf[:n]) != "World" {
t.Errorf("Expected 'World', got %q", string(readAtBuf[:n]))
}
// continue normal reading - should pick up where we left off
continueBuf := make([]byte, 2)
n, err = adapter.Read(continueBuf)
if err != nil {
t.Fatalf("Continue read failed: %v", err)
}
if string(continueBuf[:n]) != ", " {
t.Errorf("Expected ', ', got %q", string(continueBuf[:n]))
}
})
}
func TestReaderAtAdapter_Close(t *testing.T) {
reader := newReadSeekCloser(bytes.NewReader([]byte("test data")))
adapter := newReaderAtAdapter(reader)
// test that adapter can be closed
err := adapter.Close()
if err != nil {
t.Errorf("Close failed: %v", err)
}
if !reader.closed {
t.Error("Underlying reader was not closed")
}
}
type readSeekCloser struct {
io.ReadSeeker
closed bool
}
func newReadSeekCloser(rs io.ReadSeeker) *readSeekCloser {
return &readSeekCloser{ReadSeeker: rs}
}
func (r *readSeekCloser) Close() error {
r.closed = true
return nil
}