From 58067aeba5886910bc5dc11c95abaccd48a16098 Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Sun, 19 Jan 2025 12:01:37 +1100 Subject: [PATCH 1/7] Integration testing with testcontainers --- testcontainers/atmoz.go | 52 ++ testcontainers/azurite.go | 52 ++ testcontainers/backend_integration_test.go | 863 +++++++++++++++++++++ testcontainers/doc.go | 5 + testcontainers/gcsserver.go | 69 ++ testcontainers/go.mod | 147 ++++ testcontainers/go.sum | 364 +++++++++ testcontainers/io_integration_test.go | 518 +++++++++++++ testcontainers/localstack.go | 51 ++ testcontainers/minio.go | 46 ++ testcontainers/vsftpd.go | 48 ++ 11 files changed, 2215 insertions(+) create mode 100644 testcontainers/atmoz.go create mode 100644 testcontainers/azurite.go create mode 100644 testcontainers/backend_integration_test.go create mode 100644 testcontainers/doc.go create mode 100644 testcontainers/gcsserver.go create mode 100644 testcontainers/go.mod create mode 100644 testcontainers/go.sum create mode 100644 testcontainers/io_integration_test.go create mode 100644 testcontainers/localstack.go create mode 100644 testcontainers/minio.go create mode 100644 testcontainers/vsftpd.go diff --git a/testcontainers/atmoz.go b/testcontainers/atmoz.go new file mode 100644 index 00000000..04e9d8f7 --- /dev/null +++ b/testcontainers/atmoz.go @@ -0,0 +1,52 @@ +package testcontainers + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/crypto/ssh" + + "github.com/c2fo/vfs/v7/backend" + "github.com/c2fo/vfs/v7/backend/sftp" +) + +const ( + atmozPort = "22/tcp" + atmozUsername = "dummy" + atmozPassword = "dummy" +) + +func registerAtmoz(t *testing.T) string { + ctx := context.Background() + is := require.New(t) + + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Name: "vfs-atmoz-sftp", + Image: "atmoz/sftp:alpine", + Env: map[string]string{"SFTP_USERS": fmt.Sprintf("%s:%s:::upload", atmozUsername, atmozPassword)}, + WaitingFor: wait.ForListeningPort(atmozPort), + }, + Started: true, + } + ctr, err := testcontainers.GenericContainer(ctx, req) + testcontainers.CleanupContainer(t, ctr) + is.NoError(err) + + host, err := ctr.Host(ctx) + is.NoError(err) + + port, err := ctr.MappedPort(ctx, atmozPort) + is.NoError(err) + + authority := fmt.Sprintf("sftp://%s@%s:%s/upload/", atmozUsername, host, port.Port()) + backend.Register(authority, sftp.NewFileSystem(sftp.WithOptions(sftp.Options{ + Password: vsftpdPassword, + KnownHostsCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec + }))) + return authority +} diff --git a/testcontainers/azurite.go b/testcontainers/azurite.go new file mode 100644 index 00000000..7067bf7b --- /dev/null +++ b/testcontainers/azurite.go @@ -0,0 +1,52 @@ +package testcontainers + +import ( + "context" + "net/url" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/azure/azurite" + + "github.com/c2fo/vfs/v7/backend" + "github.com/c2fo/vfs/v7/backend/azure" +) + +func registerAzurite(t *testing.T) string { + ctx := context.Background() + is := require.New(t) + + ctr, err := azurite.Run(ctx, "mcr.microsoft.com/azure-storage/azurite:latest", + testcontainers.WithName("vfs-azurite"), + azurite.WithEnabledServices(azurite.BlobService), + ) + testcontainers.CleanupContainer(t, ctr) + is.NoError(err) + + ep, err := ctr.BlobServiceURL(ctx) + is.NoError(err) + + cred, err := azblob.NewSharedKeyCredential(azurite.AccountName, azurite.AccountKey) + is.NoError(err) + + u, err := url.JoinPath(ep, azurite.AccountName) + is.NoError(err) + + cli, err := azblob.NewClientWithSharedKeyCredential(u, cred, nil) + is.NoError(err) + + _, err = cli.CreateContainer(ctx, "azurite", nil) + is.NoError(err) + + c, err := azure.NewClient(&azure.Options{ + ServiceURL: u, + AccountName: azurite.AccountName, + AccountKey: azurite.AccountKey, + }) + is.NoError(err) + + backend.Register("https://azurite/", azure.NewFileSystem(azure.WithClient(c))) + return "https://azurite/" +} diff --git a/testcontainers/backend_integration_test.go b/testcontainers/backend_integration_test.go new file mode 100644 index 00000000..c1bc0907 --- /dev/null +++ b/testcontainers/backend_integration_test.go @@ -0,0 +1,863 @@ +package testcontainers + +import ( + "fmt" + "io" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/vfs/v7" + "github.com/c2fo/vfs/v7/utils" + "github.com/c2fo/vfs/v7/vfssimple" +) + +type vfsTestSuite struct { + suite.Suite + testLocations map[string]vfs.Location +} + +func buildExpectedURI(fs vfs.FileSystem, authorityStr, p string) string { + return fmt.Sprintf("%s://%s%s", fs.Scheme(), authorityStr, p) +} + +func (s *vfsTestSuite) SetupSuite() { + registers := []func(*testing.T) string{ + registerMem, + registerOS, + registerAtmoz, + registerAzurite, + registerGCSServer, + registerLocalStack, + registerMinio, + registerVSFTPD, + } + uris := make([]string, len(registers)) + var wg sync.WaitGroup + for i := range registers { + wg.Add(1) + go func() { + uris[i] = registers[i](s.T()) + wg.Done() + }() + } + wg.Wait() + + s.testLocations = make(map[string]vfs.Location) + for _, u := range uris { + l, err := vfssimple.NewLocation(u) + s.Require().NoError(err) + s.testLocations[l.FileSystem().Scheme()] = l + } +} + +func registerMem(*testing.T) string { + return "mem://test/" +} + +func registerOS(t *testing.T) string { + return fmt.Sprintf("file://%s/", filepath.ToSlash(t.TempDir())) +} + +// Test Scheme +func (s *vfsTestSuite) TestScheme() { + for scheme, location := range s.testLocations { + s.Run(scheme, func() { + fmt.Printf("************** TESTING scheme: %s **************\n", scheme) + s.FileSystem(location) + s.Location(location) + s.File(location) + }) + } +} + +// Test FileSystem +func (s *vfsTestSuite) FileSystem(baseLoc vfs.Location) { + fmt.Println("****** testing vfs.FileSystem ******") + + // setup FileSystem + fs := baseLoc.FileSystem() + // NewFile initializes a File on the specified authority string at path 'absFilePath'. + // + // * Accepts authority and an absolute file path. + // * Upon success, a vfs.File, representing the file's new path (location path + file relative path), will be returned. + // * On error, nil is returned for the file. + // * Note that not all file systems will have an "authority" and will therefore be "": + // file:///path/to/file has an authority of "" and name /path/to/file + // whereas + // s3://mybucket/path/to/file has an authority of "mybucket and name /path/to/file + // results in /tmp/dir1/newerdir/file.txt for the final vfs.File path. + // * The file may or may not already exist. + filepaths := map[string]bool{ + "/path/to/file.txt": true, + "/path/./to/file.txt": true, + "/path/../to/file.txt": true, + "path/to/file.txt": false, + "./path/to/file.txt": false, + "../path/to/": false, + "/path/to/": false, + "": false, + } + for name, validates := range filepaths { + file, err := fs.NewFile(baseLoc.Authority().String(), name) + if validates { + s.Require().NoError(err, "there should be no error") + expected := buildExpectedURI(fs, baseLoc.Authority().String(), path.Clean(name)) + s.Equal(expected, file.URI(), "uri's should match") + } else { + s.Require().Error(err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name) + } + } + + // NewLocation initializes a Location on the specified authority with the given path. + // + // * Accepts authority and an absolute location path. + // * The file may or may not already exist. Note that on key-store file systems like S3 or GCS, paths never truly exist. + // * On error, nil is returned for the location. + // + // See NewFile for note on authority. + locpaths := map[string]bool{ + "/path/to/": true, + "/path/./to/": true, + "/path/../to/": true, + "path/to/": false, + "./path/to/": false, + "../path/to/": false, + "/path/to/file.txt": false, + "": false, + } + for name, validates := range locpaths { + loc, err := fs.NewLocation(baseLoc.Authority().String(), name) + if validates { + s.Require().NoError(err, "there should be no error") + expected := buildExpectedURI(fs, baseLoc.Authority().String(), utils.EnsureTrailingSlash(path.Clean(name))) + s.Equal(expected, loc.URI(), "uri's should match") + } else { + s.Require().Error(err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name) + } + } +} + +// Test Location +func (s *vfsTestSuite) Location(baseLoc vfs.Location) { + fmt.Println("****** testing vfs.Location ******") + + srcLoc, err := baseLoc.NewLocation("locTestSrc/") + s.Require().NoError(err, "there should be no error") + defer func() { + // clean up srcLoc after test for OS + if srcLoc.FileSystem().Scheme() == "file" { + exists, err := srcLoc.Exists() + s.Require().NoError(err) + if exists { + s.Require().NoError(os.RemoveAll(srcLoc.Path()), "failed to clean up location test srcLoc") + } + } + }() + + // NewLocation is an initializer for a new Location relative to the existing one. + // + // Given location: + // loc := fs.NewLocation(:s3://mybucket/some/path/to/") + // calling: + // newLoc := loc.NewLocation("../../") + // would return a new vfs.Location representing: + // s3://mybucket/some/ + // + // * Accepts a relative location path. + locpaths := map[string]bool{ + "/path/to/": false, + "/path/./to/": false, + "/path/../to/": false, + "path/to/": true, + "./path/to/": true, + "../path/to/": true, + "/path/to/file.txt": false, + "": false, + } + for name, validates := range locpaths { + loc, err := srcLoc.NewLocation(name) + if validates { + s.Require().NoError(err, "there should be no error") + expected := buildExpectedURI(srcLoc.FileSystem(), baseLoc.Authority().String(), + utils.EnsureTrailingSlash(path.Clean(path.Join(srcLoc.Path(), name)))) + s.Equal(expected, loc.URI(), "uri's should match") + } else { + s.Require().Error(err, "should have validation error for scheme and name: %s : %s", srcLoc.FileSystem().Scheme(), name) + } + } + + // NewFile will instantiate a vfs.File instance at or relative to the current location's path. + // + // * Accepts a relative file path. + // * In the case of an error, nil is returned for the file. + // * Resultant File path will be the shortest path name equivalent of combining the Location path and relative path, if any. + // ie, /tmp/dir1/ as location and relFilePath "newdir/./../newerdir/file.txt" + // results in /tmp/dir1/newerdir/file.txt for the final vfs.File path. + // * Upon success, a vfs.File, representing the file's new path (location path + file relative path), will be returned. + // * The file may or may not already exist. + filepaths := map[string]bool{ + "/path/to/file.txt": false, + "/path/./to/file.txt": false, + "/path/../to/file.txt": false, + "path/to/file.txt": true, + "./path/to/file.txt": true, + "../path/to/": false, + "../path/to/file.txt": true, + "/path/to/": false, + "": false, + } + for name, validates := range filepaths { + file, err := srcLoc.NewFile(name) + if validates { + s.Require().NoError(err, "there should be no error") + expected := buildExpectedURI(srcLoc.FileSystem(), srcLoc.Authority().String(), path.Clean(path.Join(srcLoc.Path(), name))) + s.Equal(expected, file.URI(), "uri's should match") + } else { + s.Require().Error(err, "should have validation error for scheme and name: %s : +%s+", srcLoc.FileSystem().Scheme(), name) + } + } + + // ChangeDir updates the existing Location's path to the provided relative location path. + + // Given location: + // loc := fs.NewLocation("file:///some/path/to/") + // calling: + // loc.ChangeDir("../../") + // would update the current location instance to + // file:///some/. + // + // * ChangeDir accepts a relative location path. + + // setup test + cdTestLoc, err := srcLoc.NewLocation("chdirTest/") + s.Require().NoError(err) + + _, err = cdTestLoc.NewLocation("") + s.Require().Error(err, "empty string should error") + _, err = cdTestLoc.NewLocation("/home/") + s.Require().Error(err, "absolute path should error") + _, err = cdTestLoc.NewLocation("file.txt") + s.Require().Error(err, "file should error") + cdTestLoc, err = cdTestLoc.NewLocation("l1dir1/./l2dir1/../l2dir2/") + s.Require().NoError(err, "should be no error for relative path") + + // Path returns absolute location path, ie /some/path/to/. + // ==== Path() string + s.True(strings.HasSuffix(cdTestLoc.Path(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved") + s.True(strings.HasPrefix(cdTestLoc.Path(), "/"), "should start with slash (abs path)") + + // URI returns the fully qualified URI for the Location. IE, s3://bucket/some/path/ + // + // URI's for locations must always end with a separator character. + s.True(strings.HasSuffix(cdTestLoc.URI(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved") + prefix := cdTestLoc.FileSystem().Scheme() + "://" + s.True(strings.HasPrefix(cdTestLoc.URI(), prefix), "should start with schema and abs slash") + + /* Exists returns boolean if the location exists on the file system. Returns an error if any. + + TODO: ************************************************************************************************************* + note that Exists is not consistent among implementations. GCSs and S3 always return true if the bucket exist. + Fundamentally, why one wants to know if location exists is to know whether you're able to write there. But + this feels unintuitive. + ************************************************************************************************************* + + Consider: + + // CREATE LOCATION INSTANCE + loc, _ := vfssimple.NewLocation("scheme://vol/path/") + + // DO EXISTS CHECK ON LOCATION + if !loc.Exists() { + // CREATE LOCATION ON OS + } + + // CREATE FILE IN LOCATION AND DO WORK + myfile, _ := loc.NewFile("myfile.txt") + myfile.Write("write some text") + myfile.Close() + + Now consider if the context is os/sftp OR gcs/s3/mem. + + ==== Exists() (bool, error) + */ + exists, err := baseLoc.Exists() + s.Require().NoError(err) + s.True(exists, "baseLoc location exists check") + + // setup list tests + f1, err := srcLoc.NewFile("file1.txt") + s.Require().NoError(err) + _, err = f1.Write([]byte("this is a test file")) + s.Require().NoError(err) + s.Require().NoError(f1.Close()) + + f2, err := srcLoc.NewFile("file2.txt") + s.Require().NoError(err) + s.Require().NoError(f1.CopyToFile(f2)) + s.Require().NoError(f1.Close()) + + f3, err := srcLoc.NewFile("self.txt") + s.Require().NoError(err) + s.Require().NoError(f1.CopyToFile(f3)) + s.Require().NoError(f1.Close()) + + subLoc, err := srcLoc.NewLocation("somepath/") + s.Require().NoError(err) + + f4, err := subLoc.NewFile("that.txt") + s.Require().NoError(err) + s.Require().NoError(f1.CopyToFile(f4)) + s.Require().NoError(f1.Close()) + + // List returns a slice of strings representing the base names of the files found at the Location. + // + // * All implementations are expected to return ([]string{}, nil) in the case of a non-existent directory/prefix/location. + // * If the user cares about the distinction between an empty location and a non-existent one, Location.Exists() should + // be checked first. + // ==== List() ([]string, error) + + files, err := srcLoc.List() + s.Require().NoError(err) + s.Len(files, 3, "list srcLoc location") + + files, err = subLoc.List() + s.Require().NoError(err) + s.Len(files, 1, "list subLoc location") + s.Equal("that.txt", files[0], "returned basename") + + files, err = cdTestLoc.List() + s.Require().NoError(err) + s.Empty(files, "non-existent location") + + // ListByPrefix returns a slice of strings representing the base names of the files found in Location whose filenames + // match the given prefix. + // + // * All implementations are expected to return ([]string{}, nil) in the case of a non-existent directory/prefix/location. + // * "relative" prefixes are allowed, ie, ListByPrefix() from location "/some/path/" with prefix "to/somepattern" + // is the same as location "/some/path/to/" with prefix of "somepattern" + // * If the user cares about the distinction between an empty location and a non-existent one, Location.Exists() should + // be checked first. + // ==== ListByPrefix(prefix string) ([]string, error) + + files, err = srcLoc.ListByPrefix("file") + s.Require().NoError(err) + s.Len(files, 2, "list srcLoc location matching prefix") + + files, err = srcLoc.ListByPrefix("s") + s.Require().NoError(err) + s.Len(files, 1, "list srcLoc location") + s.Equal("self.txt", files[0], "returned only file basename, not subdir matching prefix") + + files, err = srcLoc.ListByPrefix("somepath/t") + s.Require().NoError(err) + s.Len(files, 1, "list 'somepath' location relative to srcLoc") + s.Equal("that.txt", files[0], "returned only file basename, using relative prefix") + + files, err = cdTestLoc.List() + s.Require().NoError(err) + s.Empty(files, "non-existent location") + + // ListByRegex returns a slice of strings representing the base names of the files found in the Location that matched the + // given regular expression. + // + // * All implementations are expected to return ([]string{}, nil) in the case of a non-existent directory/prefix/location. + // * If the user cares about the distinction between an empty location and a non-existent one, Location.Exists() should + // be checked first. + // ==== ListByRegex(regex *regexp.Regexp) ([]string, error) + + files, err = srcLoc.ListByRegex(regexp.MustCompile("^f")) + s.Require().NoError(err) + s.Len(files, 2, "list srcLoc location matching prefix") + + files, err = srcLoc.ListByRegex(regexp.MustCompile(`.txt$`)) + s.Require().NoError(err) + s.Len(files, 3, "list srcLoc location matching prefix") + + files, err = srcLoc.ListByRegex(regexp.MustCompile(`Z`)) + s.Require().NoError(err) + s.Empty(files, "list srcLoc location matching prefix") + + // DeleteFile deletes the file of the given name at the location. + // + // This is meant to be a short cut for instantiating a new file and calling delete on that, with all the necessary + // error handling overhead. + // + // * Accepts relative file path. + // + // ==== DeleteFile(fileName string) error + s.Require().NoError(srcLoc.DeleteFile(f1.Name()), "deleteFile file1") + s.Require().NoError(srcLoc.DeleteFile(f2.Name()), "deleteFile file2") + s.Require().NoError(srcLoc.DeleteFile(f3.Name()), "deleteFile self.txt") + s.Require().NoError(srcLoc.DeleteFile("somepath/that.txt"), "deleted relative path") + + // should error if file doesn't exist + s.Require().Error(srcLoc.DeleteFile(f1.Path()), "deleteFile trying to delete a file already deleted") +} + +// Test File +func (s *vfsTestSuite) File(baseLoc vfs.Location) { + fmt.Println("****** testing vfs.File ******") + srcLoc, err := baseLoc.NewLocation("fileTestSrc/") + s.Require().NoError(err) + defer func() { + // clean up srcLoc after test for OS + if srcLoc.FileSystem().Scheme() == "file" { + exists, err := srcLoc.Exists() + s.Require().NoError(err) + if exists { + s.Require().NoError(os.RemoveAll(srcLoc.Path()), "failed to clean up file test srcLoc") + } + } + }() + + // setup srcFile + srcFile, err := srcLoc.NewFile("srcFile.txt") + s.Require().NoError(err) + + /* + Location returns the vfs.Location for the File. + + Location() Location + */ + + /* + io.Writer + */ + sz, err := srcFile.Write([]byte("this is a test\n")) + s.Require().NoError(err) + s.Equal(15, sz) + sz, err = srcFile.Write([]byte("and more text")) + s.Require().NoError(err) + s.Equal(13, sz) + + /* + io.Closer + */ + err = srcFile.Close() + s.Require().NoError(err) + + /* + Exists returns boolean if the file exists on the file system. Also returns an error if any. + + Exists() (bool, error) + */ + exists, err := srcFile.Exists() + s.Require().NoError(err) + s.True(exists, "file exists") + + /* + Name returns the base name of the file path. For file:///some/path/to/file.txt, it would return file.txt + + Name() string + */ + s.Equal("srcFile.txt", srcFile.Name(), "name test") + + /* + Path returns absolute path (with leading slash) including filename, ie /some/path/to/file.txt + + Path() string + */ + s.Equal(path.Join(baseLoc.Path(), "fileTestSrc/srcFile.txt"), srcFile.Path(), "path test") + + /* + URI returns the fully qualified URI for the File. IE, s3://bucket/some/path/to/file.txt + + URI() string + */ + s.Equal(baseLoc.URI()+"fileTestSrc/srcFile.txt", srcFile.URI(), "uri test") + + /* + String() must be implemented to satisfy the stringer interface. This ends up simply calling URI(). + + fmt.Stringer + */ + s.Equal(baseLoc.URI()+"fileTestSrc/srcFile.txt", srcFile.String(), "string(er) explicit test") + s.Equal(baseLoc.URI()+"fileTestSrc/srcFile.txt", fmt.Sprintf("%s", srcFile), "string(er) implicit test") //nolint:gocritic,staticcheck + + /* + Size returns the size of the file in bytes. + + Size() (uint64, error) + */ + b, err := srcFile.Size() + s.Require().NoError(err) + s.Equal(uint64(28), b) + + /* + LastModified returns the timestamp the file was last modified (as *time.Time). + + LastModified() (*time.Time, error) + */ + t, err := srcFile.LastModified() + s.Require().NoError(err) + s.IsType((*time.Time)(nil), t, "last modified returned *time.Time") + + /* + Exists returns boolean if the file exists on the file system. Also returns an error if any. + + Exists() (bool, error) + */ + exists, err = srcFile.Exists() + s.Require().NoError(err) + s.True(exists, "file exists") + + /* + io.Reader and io.Seeker + */ + str, err := io.ReadAll(srcFile) + s.Require().NoError(err) + s.Equal("this is a test\nand more text", string(str), "read was successful") + + offset, err := srcFile.Seek(3, 0) + s.Require().NoError(err) + s.Equal(int64(3), offset, "seek was successful") + + str, err = io.ReadAll(srcFile) + s.Require().NoError(err) + s.Equal("s is a test\nand more text", string(str), "read after seek") + err = srcFile.Close() + s.Require().NoError(err) + + for _, testLoc := range s.testLocations { + // setup dstLoc + dstLoc, err := testLoc.NewLocation("dstLoc/") + s.Require().NoError(err) + fmt.Printf("** location %s **\n", dstLoc) + if dstLoc.FileSystem().Scheme() == "file" { + s.T().Cleanup(func() { + // clean up dstLoc after test for OS + exists, err := dstLoc.Exists() + s.Require().NoError(err) + if exists { + s.Require().NoError(os.RemoveAll(dstLoc.Path()), "failed to clean up file test dstLoc") + } + }) + } + + // CopyToLocation will copy the current file to the provided location. + // + // * Upon success, a vfs.File, representing the file at the new location, will be returned. + // * In the case of an error, nil is returned for the file. + // * CopyToLocation should use native functions when possible within the same scheme. + // * If the file already exists at the location, the contents will be overwritten with the current file's contents. + _, err = srcFile.Seek(0, 0) + s.Require().NoError(err) + dst, err := srcFile.CopyToLocation(dstLoc) + s.Require().NoError(err) + exists, err := dst.Exists() + s.Require().NoError(err) + s.True(exists, "dst file should now exist") + exists, err = srcFile.Exists() + s.Require().NoError(err) + s.True(exists, "src file should still exist") + + // CopyToFile will copy the current file to the provided file instance. + // + // * In the case of an error, nil is returned for the file. + // * CopyToLocation should use native functions when possible within the same scheme. + // * If the file already exists, the contents will be overwritten with the current file's contents. + + // setup dstFile + dstFile1, err := dstLoc.NewFile("dstFile1.txt") + s.Require().NoError(err) + exists, err = dstFile1.Exists() + s.Require().NoError(err) + s.False(exists, "dstFile1 file should not yet exist") + _, err = srcFile.Seek(0, 0) + s.Require().NoError(err) + err = srcFile.CopyToFile(dstFile1) + s.Require().NoError(err) + exists, err = dstFile1.Exists() + s.Require().NoError(err) + s.True(exists, "dstFile1 file should now exist") + exists, err = srcFile.Exists() + s.Require().NoError(err) + s.True(exists, "src file should still exist") + + /* + io.Copy + */ + // create a local copy from srcFile with io.Copy + copyFile1, err := srcLoc.NewFile("copyFile1.txt") + s.Require().NoError(err) + // should not exist + exists, err = copyFile1.Exists() + s.Require().NoError(err) + s.False(exists, "copyFile1 should not yet exist locally") + // do copy + // skip this test for ftp files + buffer := make([]byte, utils.TouchCopyMinBufferSize) + + if srcLoc.FileSystem().Scheme() != "ftp" { + _, err = srcFile.Seek(0, 0) + s.Require().NoError(err) + b1, err := io.CopyBuffer(copyFile1, srcFile, buffer) + s.Require().NoError(err) + s.Equal(int64(28), b1) + err = copyFile1.Close() + s.Require().NoError(err) + + // should now exist + exists, err = copyFile1.Exists() + s.Require().NoError(err) + s.True(exists, "%s should now exist locally", copyFile1) + err = copyFile1.Close() + s.Require().NoError(err) + } else { + // else still have to ensure copyFile1 exists for later tests + err = copyFile1.Touch() + s.Require().NoError(err) + } + + // create another local copy from srcFile with io.Copy + copyFile2, err := srcLoc.NewFile("copyFile2.txt") + s.Require().NoError(err) + // should not exist + exists, err = copyFile2.Exists() + s.Require().NoError(err) + s.False(exists, "copyFile2 should not yet exist locally") + // do copy + // skip this test for ftp files + if srcLoc.FileSystem().Scheme() != "ftp" { + _, err = srcFile.Seek(0, 0) + s.Require().NoError(err) + buffer = make([]byte, utils.TouchCopyMinBufferSize) + b2, err := io.CopyBuffer(copyFile2, srcFile, buffer) + s.Require().NoError(err) + s.Equal(int64(28), b2) + + err = copyFile2.Close() + s.Require().NoError(err) + // should now exist + exists, err = copyFile2.Exists() + s.Require().NoError(err) + s.True(exists, "copyFile2 should now exist locally") + err = copyFile2.Close() + s.Require().NoError(err) + } else { + // else still have to ensure copyFile1 exists for later tests + err = copyFile2.Touch() + s.Require().NoError(err) + } + + // MoveToLocation will move the current file to the provided location. + // + // * If the file already exists at the location, the contents will be overwritten with the current file's contents. + // * If the location does not exist, an attempt will be made to create it. + // * Upon success, a vfs.File, representing the file at the new location, will be returned. + // * In the case of an error, nil is returned for the file. + // * When moving within the same Scheme, native move/rename should be used where possible. + // * If the file already exists, the contents will be overwritten with the current file's contents. + fileForNew, err := srcLoc.NewFile("fileForNew.txt") + s.Require().NoError(err) + + // skip this test for ftp files + if srcLoc.FileSystem().Scheme() != "ftp" { + _, err = srcFile.Seek(0, 0) + s.Require().NoError(err) + buffer = make([]byte, utils.TouchCopyMinBufferSize) + _, err = io.CopyBuffer(fileForNew, srcFile, buffer) + s.Require().NoError(err) + err = fileForNew.Close() + s.Require().NoError(err) + + newLoc, err := dstLoc.NewLocation("doesnotexist/") + s.Require().NoError(err) + dstCopyNew, err := fileForNew.MoveToLocation(newLoc) + s.Require().NoError(err) + exists, err = dstCopyNew.Exists() + s.Require().NoError(err) + s.True(exists) + s.Require().NoError(dstCopyNew.Delete()) // clean up file + + s.Require().NoError(srcFile.Close()) + } + + dstCopy1, err := copyFile1.MoveToLocation(dstLoc) + s.Require().NoError(err) + // destination file should now exist + exists, err = dstCopy1.Exists() + s.Require().NoError(err) + s.True(exists, "dstCopy1 file should now exist") + // local copy should no longer exist + exists, err = copyFile1.Exists() + s.Require().NoError(err) + s.False(exists, "copyFile1 should no longer exist locally") + + // MoveToFile will move the current file to the provided file instance. + // + // * If the file already exists, the contents will be overwritten with the current file's contents. + // * The current instance of the file will be removed. + dstCopy2, err := dstLoc.NewFile("dstFile2.txt") + s.Require().NoError(err) + // destination file should not exist + exists, err = dstCopy2.Exists() + s.Require().NoError(err) + s.False(exists, "dstCopy2 file should not yet exist") + // do move file + err = copyFile2.MoveToFile(dstCopy2) + s.Require().NoError(err) + // local copy should no longer exist + exists, err = copyFile2.Exists() + s.Require().NoError(err) + s.False(exists, "copyFile2 should no longer exist locally") + // destination file should now exist + exists, err = dstCopy2.Exists() + s.Require().NoError(err) + s.True(exists, "dstCopy2 file should now exist") + + // clean up files + err = dst.Delete() + s.Require().NoError(err) + err = dstFile1.Delete() + s.Require().NoError(err) + err = dstCopy1.Delete() + s.Require().NoError(err) + err = dstCopy2.Delete() + s.Require().NoError(err) + + // ensure that MoveToFile() works for files with spaces + type moveSpaceTest struct { + Path, Filename string + } + tests := []moveSpaceTest{ + {Path: "file", Filename: "has space.txt"}, + {Path: "file", Filename: "has%20encodedSpace.txt"}, + {Path: "path has", Filename: "space.txt"}, + {Path: "path%20has", Filename: "encodedSpace.txt"}, + } + + for i, test := range tests { + s.Run(strconv.Itoa(i), func() { + // setup src + srcSpaces, err := srcLoc.NewFile(path.Join(test.Path, test.Filename)) + s.Require().NoError(err) + b, err := srcSpaces.Write([]byte("something")) + s.Require().NoError(err) + s.Equal(9, b, "byte count is correct") + err = srcSpaces.Close() + s.Require().NoError(err) + + testDestLoc, err := dstLoc.NewLocation(test.Path + "/") + s.Require().NoError(err) + + dstSpaces, err := srcSpaces.MoveToLocation(testDestLoc) + s.Require().NoError(err) + exists, err := dstSpaces.Exists() + s.Require().NoError(err) + s.True(exists, "dstSpaces should now exist") + exists, err = srcSpaces.Exists() + s.Require().NoError(err) + s.False(exists, "srcSpaces should no longer exist") + s.True( + strings.HasSuffix(dstSpaces.URI(), path.Join(test.Path, test.Filename)) || + strings.HasSuffix(dstSpaces.URI(), path.Join(url.PathEscape(test.Path), url.PathEscape(test.Filename))), + "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename), + ) + + newSrcSpaces, err := dstSpaces.MoveToLocation(srcSpaces.Location()) + s.Require().NoError(err) + exists, err = newSrcSpaces.Exists() + s.Require().NoError(err) + s.True(exists, "newSrcSpaces should now exist") + exists, err = dstSpaces.Exists() + s.Require().NoError(err) + s.False(exists, "dstSpaces should no longer exist") + hasSuffix := strings.HasSuffix(newSrcSpaces.URI(), path.Join(test.Path, test.Filename)) || + strings.HasSuffix(newSrcSpaces.URI(), path.Join(url.PathEscape(test.Path), url.PathEscape(test.Filename))) + s.True(hasSuffix, "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename)) + + err = newSrcSpaces.Delete() + s.Require().NoError(err) + exists, err = newSrcSpaces.Exists() + s.Require().NoError(err) + s.False(exists, "newSrcSpaces should now exist") + }) + } + } + + // Touch creates a zero-length file on the vfs.File if no File exists. Update File's last modified timestamp. + // Returns error if unable to touch File. + + touchedFile, err := srcLoc.NewFile("touch.txt") + s.Require().NoError(err) + defer func() { _ = touchedFile.Delete() }() + exists, err = touchedFile.Exists() + s.Require().NoError(err) + s.False(exists, "%s shouldn't yet exist", touchedFile) + + err = touchedFile.Touch() + s.Require().NoError(err) + exists, err = touchedFile.Exists() + s.Require().NoError(err) + s.True(exists, "%s now exists", touchedFile) + + size, err := touchedFile.Size() + s.Require().NoError(err) + s.Zero(size, "%s should be empty", touchedFile) + + // capture last modified + modified, err := touchedFile.LastModified() + s.Require().NoError(err) + modifiedDeRef := *modified + // wait for eventual consistency + if baseLoc.FileSystem().Scheme() == "ftp" { + time.Sleep(time.Duration(61-time.Now().Second()) * time.Second) + } else { + time.Sleep(time.Second) + } + err = touchedFile.Touch() + s.Require().NoError(err) + newModified, err := touchedFile.LastModified() + s.Require().NoError(err) + s.Greater(newModified.UnixNano(), modifiedDeRef.UnixNano(), "touch updated modified date for %s", touchedFile) + + /* + Delete unlinks the File on the file system. + + Delete() error + */ + err = srcFile.Delete() + s.Require().NoError(err) + exists, err = srcFile.Exists() + s.Require().NoError(err) + s.False(exists, "file no longer exists") + + // The following blocks test that an error is thrown when these operations are called on a non-existent file + srcFile, err = srcLoc.NewFile("thisFileDoesNotExist") + s.Require().NoError(err, "unexpected error creating file") + + exists, err = srcFile.Exists() + s.Require().NoError(err) + s.False(exists, "file should not exist") + + size, err = srcFile.Size() + s.Require().Error(err, "expected error because file does not exist") + s.Zero(size) + + _, err = srcFile.LastModified() + s.Require().Error(err, "expected error because file does not exist") + + seeked, err := srcFile.Seek(-1, 2) + s.Require().Error(err, "expected error because file does not exist") + s.Zero(seeked) + + _, err = srcFile.Read(make([]byte, 1)) + s.Require().Error(err, "expected error because file does not exist") + + // end existence tests +} + +func TestVFS(t *testing.T) { + suite.Run(t, new(vfsTestSuite)) +} diff --git a/testcontainers/doc.go b/testcontainers/doc.go new file mode 100644 index 00000000..0cc533c9 --- /dev/null +++ b/testcontainers/doc.go @@ -0,0 +1,5 @@ +/* +Package testcontainers is meant to be run by implementors of backends to ensure that the behaviors of their backend matches the +expected behavior of the interface. It uses the local Docker daemon to run servers that emulate popular storage services. +*/ +package testcontainers diff --git a/testcontainers/gcsserver.go b/testcontainers/gcsserver.go new file mode 100644 index 00000000..a2aba2ea --- /dev/null +++ b/testcontainers/gcsserver.go @@ -0,0 +1,69 @@ +package testcontainers + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "strings" + "testing" + + "cloud.google.com/go/storage" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "google.golang.org/api/option" + + "github.com/c2fo/vfs/v7/backend" + "github.com/c2fo/vfs/v7/backend/gs" +) + +const gcsServerPort = "4443/tcp" + +func registerGCSServer(t *testing.T) string { + ctx := context.Background() + is := require.New(t) + + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Name: "vfs-fake-gcs-server", + Image: "fsouza/fake-gcs-server:latest", + Entrypoint: []string{"/bin/fake-gcs-server", "-backend", "memory"}, + WaitingFor: wait.ForHTTP("/_internal/healthcheck").WithTLS(true).WithAllowInsecure(true).WithPort(gcsServerPort), + }, + Started: true, + } + ctr, err := testcontainers.GenericContainer(ctx, req) + testcontainers.CleanupContainer(t, ctr) + is.NoError(err) + + host, err := ctr.Host(ctx) + is.NoError(err) + port, err := ctr.MappedPort(ctx, gcsServerPort) + is.NoError(err) + ep := fmt.Sprintf("https://%s:%s", host, port.Port()) + + hc := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + }} + configJSON := strings.NewReader(fmt.Sprintf(`{"publicHost":"%s:%s"}`, host, port.Port())) + hreq, err := http.NewRequest(http.MethodPut, ep+"/_internal/config", configJSON) + is.NoError(err) + res, err := hc.Do(hreq) + is.NoError(err) + _ = res.Body.Close() + is.Equal(http.StatusOK, res.StatusCode) + + cli, err := storage.NewClient(ctx, + option.WithHTTPClient(hc), + option.WithEndpoint(ep+"/storage/v1/"), + option.WithoutAuthentication(), + ) + is.NoError(err) + + err = cli.Bucket("gcsserver").Create(ctx, "", &storage.BucketAttrs{VersioningEnabled: true}) + is.NoError(err) + + backend.Register("gs://gcsserver/", gs.NewFileSystem(gs.WithClient(cli))) + return "gs://gcsserver/" +} diff --git a/testcontainers/go.mod b/testcontainers/go.mod new file mode 100644 index 00000000..bf218ccd --- /dev/null +++ b/testcontainers/go.mod @@ -0,0 +1,147 @@ +module github.com/c2fo/vfs/testcontainers + +go 1.24.11 + +replace github.com/c2fo/vfs/v7 => .. + +require ( + cloud.google.com/go/storage v1.59.1 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 + github.com/c2fo/vfs/v7 v7.13.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/azure v0.40.0 + github.com/testcontainers/testcontainers-go/modules/localstack v0.40.0 + github.com/testcontainers/testcontainers-go/modules/minio v0.40.0 + golang.org/x/crypto v0.47.0 + google.golang.org/api v0.262.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jlaffaye/ftp v0.2.1-0.20240214224549-4edb16bfcd0f // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/minio-go/v7 v7.0.95 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.10 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.25.12 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/tinylib/msgp v1.4.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.8.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto v0.0.0-20260126211449-d11affda4bed // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260126211449-d11affda4bed // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260126211449-d11affda4bed // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/testcontainers/go.sum b/testcontainers/go.sum new file mode 100644 index 00000000..f33d646d --- /dev/null +++ b/testcontainers/go.sum @@ -0,0 +1,364 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM= +cloud.google.com/go/pubsub/v2 v2.3.0 h1:DgAN907x+sP0nScYfBzneRiIhWoXcpCD8ZAut8WX9vs= +cloud.google.com/go/pubsub/v2 v2.3.0/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw= +cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= +cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.4.0 h1:mXlQ+2C8A4KpXTIIYYxgFYqSivjGTBQidq/b0xxZLuk= +github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.4.0/go.mod h1:K//Ck7MUa+r9jpV69WLeWnnju5WJx5120AFsEzvumII= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= +github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1 h1:qvrrnQ2mIjwY7IVlQuNB0ma43Nr74+9ZTZJ60KlmlV4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1/go.mod h1:FkF/Az07vR3S4sBdjCuisznWfFWOD8u6Ibm/g/oyDAk= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0 h1:pQZGI0qQXeCHZHMeWzhwPu+4jkWrdrIb2dgpG4OKmco= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0/go.mod h1:XGq5kImVqQT4HUNbbG+0Y8O74URsPNH7CGPg1s1HW5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 h1:kkHPnzHm5Ln7WA0XYjrr2ITA0l9Vs6H++Ni//P+SZso= +github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsouza/fake-gcs-server v1.52.3 h1:hXddOPMGDKq5ENmttw6xkodVJy0uVhf7HhWvQgAOH6g= +github.com/fsouza/fake-gcs-server v1.52.3/go.mod h1:A0XtSRX+zz5pLRAt88j9+Of0omQQW+RMqipFbvdNclQ= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/renameio/v2 v2.0.1 h1:HyOM6qd9gF9sf15AvhbptGHUnaLTpEI9akAFFU3VyW0= +github.com/google/renameio/v2 v2.0.1/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jlaffaye/ftp v0.2.1-0.20240214224549-4edb16bfcd0f h1:u9Rqt4DbfQ1xc7syxtnWFNU1OjcXJeVYGsiU1q3QAI4= +github.com/jlaffaye/ftp v0.2.1-0.20240214224549-4edb16bfcd0f/go.mod h1:4p8lUl4vQ80L598CygL+3IFtm+3nggvvW/palOlViwE= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= +github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= +github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/azure v0.40.0 h1:a4Qn4UEgL3uzpY1Hhuzh2c87u/CuSoTaV12timQfHQU= +github.com/testcontainers/testcontainers-go/modules/azure v0.40.0/go.mod h1:047cjSoIxghqTQt8OVeLwLO918jOTrRnKYSEG5L6paQ= +github.com/testcontainers/testcontainers-go/modules/localstack v0.40.0 h1:b+lN2Ch4J/6EwqB+Af+QQbSfv4sFGetHlBHpXi+1yJU= +github.com/testcontainers/testcontainers-go/modules/localstack v0.40.0/go.mod h1:8LuTSboTo2MJKFKV5xH6z4ZH1s3jhRJWwvtPJzKogj4= +github.com/testcontainers/testcontainers-go/modules/minio v0.40.0 h1:M+Ib1mIXq/hEcH8tyEvBnOZ7NJi03zY+P1gYO5GGp6o= +github.com/testcontainers/testcontainers-go/modules/minio v0.40.0/go.mod h1:ON0MxxS/pME0SJOKLImw/D9R1L7apYsxIZrM/uEqORA= +github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= +github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= +go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY= +google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI= +google.golang.org/genproto v0.0.0-20260126211449-d11affda4bed h1:qZW022+WR7NN5TKrr24jcoT1rTS8Qc28YBPCYq7cxIU= +google.golang.org/genproto v0.0.0-20260126211449-d11affda4bed/go.mod h1:SpjiK7gGN2j/djoQMxLl3QOe/J/XxNzC5M+YLecVVWU= +google.golang.org/genproto/googleapis/api v0.0.0-20260126211449-d11affda4bed h1:3ip6+kOPIfzoQ5Gx9IOq79L1dEoarwV51IOs24iQvZE= +google.golang.org/genproto/googleapis/api v0.0.0-20260126211449-d11affda4bed/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260126211449-d11affda4bed h1:Yyog7dFpq0nVFnxj1NymkvC4RDIzc7KILL6vNAgLbCs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260126211449-d11affda4bed/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/testcontainers/io_integration_test.go b/testcontainers/io_integration_test.go new file mode 100644 index 00000000..27fce2b1 --- /dev/null +++ b/testcontainers/io_integration_test.go @@ -0,0 +1,518 @@ +package testcontainers + +import ( + "errors" + "io" + "os" + "path" + "regexp" + "strconv" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/vfs/v7" + "github.com/c2fo/vfs/v7/options" + "github.com/c2fo/vfs/v7/vfssimple" +) + +type osWrapper struct { + filename string + file *os.File + exists bool + seekCalled bool +} + +func newOSWrapper(absPath string) *osWrapper { + return &osWrapper{ + filename: absPath, + exists: fileExists(absPath), + } +} + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func (o *osWrapper) Read(b []byte) (int, error) { + if !o.exists { + return 0, errors.New("file not found") + } + if o.file == nil { + file, err := os.OpenFile(o.filename, os.O_RDWR, 0o600) + if err != nil { + return 0, err + } + o.file = file + } + return o.file.Read(b) +} + +func (o *osWrapper) Write(b []byte) (int, error) { + if o.file == nil { + flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC + if o.seekCalled { + flags = os.O_RDWR | os.O_CREATE + } + file, err := os.OpenFile(o.filename, flags, 0o600) //nolint:gosec + if err != nil { + return 0, err + } + o.file = file + o.exists = true + } + + return o.file.Write(b) +} + +func (o *osWrapper) Seek(offset int64, whence int) (int64, error) { + if !o.exists { + return 0, errors.New("file not found") + } + + if o.file == nil { + file, err := os.OpenFile(o.filename, os.O_RDWR, 0o600) + if err != nil { + return 0, err + } + o.file = file + } + o.seekCalled = true + return o.file.Seek(offset, whence) +} + +func (o *osWrapper) Close() error { + if !o.exists { + return nil + } + err := o.file.Close() + if err != nil { + return err + } + o.file = nil + return nil +} + +func (o *osWrapper) Name() string { + return path.Base(o.filename) +} + +func (o *osWrapper) URI() string { + return o.filename +} + +func (o *osWrapper) Delete(_ ...options.DeleteOption) error { + return os.Remove(o.URI()) +} + +type readWriteSeekCloseDeleter interface { + io.ReadWriteSeeker + io.Closer + Delete(opts ...options.DeleteOption) error +} + +type ioTestSuite struct { + suite.Suite + testLocations map[string]vfs.Location + localDir string +} + +func (s *ioTestSuite) SetupSuite() { + registers := []func(*testing.T) string{ + registerMem, + registerOS, + registerAtmoz, + registerAzurite, + registerGCSServer, + registerLocalStack, + registerMinio, + registerVSFTPD, + } + uris := make([]string, len(registers)) + var wg sync.WaitGroup + for i := range registers { + wg.Add(1) + go func() { + uris[i] = registers[i](s.T()) + wg.Done() + }() + } + wg.Wait() + + s.testLocations = make(map[string]vfs.Location) + for _, u := range uris { + if strings.HasPrefix(u, "/") { + s.localDir = u + } else { + l, err := vfssimple.NewLocation(u) + s.Require().NoError(err) + s.testLocations[l.FileSystem().Scheme()] = l + } + } +} + +func (s *ioTestSuite) TestFileOperations() { + if s.localDir != "" { + s.Run("local", func() { + s.testFileOperations(s.localDir) + }) + } + for scheme, location := range s.testLocations { + s.Run(scheme, func() { + s.testFileOperations(location.URI()) + }) + } +} + +// unless seek or read is called first, writes should replace a file (not edit) + +func (s *ioTestSuite) testFileOperations(testPath string) { + testCases := []struct { + description string + sequence string + fileAlreadyExists bool + expectFailure bool + expectedResults string + }{ + // Read, Close file + { + "Read, Close, file exists", + "R(all);C()", + true, + false, + "some text", + }, + { + "Read, Close, file does not exist", + "R(all);C()", + false, + true, + "", + }, + + // Read, Seek, Read, Close + { + "Read, Seek, Read, Close, file exists", + "R(4);S(0,0);R(4);C()", + true, + false, + "some text", + }, + + // Write, Close + { + "Write, Close, file does not exist", + "W(abc);C()", + false, + false, + "abc", + }, + { + "Write, Close, file exists", + "W(abc);C()", + true, + false, + "abc", + }, + + // Write, Seek, Write, Close + { + "Write, Seek, Write, Close, file does not exist", + "W(this and that);S(0,0);W(that);C()", + false, + false, + "that and that", + }, + { + "Write, Seek, Write, Close, file exists", + "W(this and that);S(0,0);W(that);C()", + true, + false, + "that and that", + }, + + // Seek + { + "Seek, Close, file does not exist", + "S(2,0);C()", + false, + true, + "", + }, + { + "Seek, Close, file exists", + "S(2,0);C()", + true, + false, + "some text", + }, + { + "Seek, Write, Close, file exists", + "S(5,0);W(new text);C()", + true, + false, + "some new text", + }, + + // Seek, Read, Close + { + "Seek, Read, Close, file does not exist", + "S(5,0);R(4);C()", + false, + true, + "", + }, + { + "Seek, Read, Close, file exists", + "S(5,0);R(4);C()", + true, + false, + "some text", + }, + + // Read, Write, Close + { + "Read, Write, Close, file does not exist", + "R(5);W(new text);C()", + false, + true, + "", + }, + { + "Read, Write, Close, file exists", + "R(5);W(new text);C()", + true, + false, + "some new text", + }, + + // Read, Seek, Write, Close + { + "Read, Seek, Write, Close, file does not exist", + "R(2);S(3,1);W(new text);C()", + false, + true, + "", + }, + { + "Read, Seek, Write, Close, file exists", + "R(2);S(3,1);W(new text);C()", + true, + false, + "some new text", + }, + + // Write, Seek, Read, Close + { + "Write, Seek, Read, Close, file does not exist", + "W(new text);S(0,0);R(5);C()", + false, + false, + "new text", + }, + { + "Write, Seek, Read, Close, file exists", + "W(new text);S(0,0);R(5);C()", + true, + false, + "new text", + }, + } + + defer s.teardownTestLocation(testPath) + for _, tc := range testCases { + s.Run(tc.description, func() { + testFileName := "testfile.txt" + + // run in a closure so we can defer teardown + func() { + // Setup vfs environment + file, err := s.setupTestFile(tc.fileAlreadyExists, testPath, testFileName) // Implement this setup function + defer func() { + if file != nil { + _ = file.Close() + _ = file.Delete() + } + }() + s.Require().NoError(err) + + // Use vfs to execute the sequence of operations described by the description + actualContents, err := executeSequence(s.T(), file, tc.sequence) // Implement this function + + // Assert expected outcomes + if tc.expectFailure { + s.Require().Error(err, "%s: expected failure but got success", tc.description) + } else { + s.Require().NoError(err, "%s: expected success but got failure: %v", tc.description, err) + } + + s.Equal(tc.expectedResults, actualContents, "%s: expected results %s but got %s", tc.description, tc.expectedResults, actualContents) + }() + }) + } +} + +//nolint:gocyclo +func executeSequence(t *testing.T, file readWriteSeekCloseDeleter, sequence string) (string, error) { + commands := strings.Split(sequence, ";") + var commandErr error +SEQ: + for _, command := range commands { + // parse command + commandName, commandArgs := parseCommand(t, command) + + switch commandName { + case "R": + if commandArgs[0] == "all" { + // Read entire file + _, commandErr = io.ReadAll(file) + if commandErr != nil { + break SEQ + } + } else { + // convert arg 0 to uint64 + bytesize, err := strconv.ParseUint(commandArgs[0], 10, 64) + if err != nil { + t.Fatalf("invalid bytesize: %s", commandArgs[0]) + } + + // Read file + b := make([]byte, bytesize) + _, commandErr = file.Read(b) + if commandErr != nil { + break SEQ + } + } + case "W": + // Write to file + _, commandErr = file.Write([]byte(commandArgs[0])) + if commandErr != nil { + break SEQ + } + case "S": + // expect 2 args for offset and whence + if len(commandArgs) != 2 { + t.Fatalf("invalid number of args for Seek: %d", len(commandArgs)) + } + // convert args + offset, err := strconv.ParseInt(commandArgs[0], 10, 64) + if err != nil { + t.Fatalf("invalid offset: %s", commandArgs[0]) + } + whence, err := strconv.Atoi(commandArgs[1]) + if err != nil { + t.Fatalf("invalid whence: %s", commandArgs[1]) + } + // Seek + _, commandErr = file.Seek(offset, whence) + if commandErr != nil { + break SEQ + } + case "C": + // Close + commandErr = file.Close() + if commandErr != nil { + break SEQ + } + } + } + // success so compare file contents to expected results + if commandErr != nil { + return "", commandErr + } + + var f io.ReadCloser + + switch assertedFile := file.(type) { + case *osWrapper: + var err error + f, err = os.Open(assertedFile.URI()) + if err != nil { + t.Fatalf("error opening file: %s", err.Error()) + } + case vfs.File: + var err error + f, err = assertedFile.Location().NewFile(assertedFile.Name()) + if err != nil { + t.Fatalf("error opening file: %s", err.Error()) + } + } + defer func() { _ = f.Close() }() + // Read entire file + contents, err := io.ReadAll(f) + if err != nil { + t.Fatalf("error reading file: %s", err.Error()) + } + return string(contents), nil +} + +var commandArgsRegex = regexp.MustCompile(`^([a-zA-Z0-9]+)\((.*)\)$`) + +// takes command string in the form of () and returns the command name and args +func parseCommand(t *testing.T, command string) (string, []string) { + // parse command string + results := commandArgsRegex.FindStringSubmatch(command) + if len(results) != 3 { + t.Fatalf("invalid command string: %s", command) + } + + // split args by comma + args := strings.Split(results[2], ",") + + return results[1], args +} + +func (s *ioTestSuite) setupTestFile(existsBefore bool, loc, filename string) (readWriteSeekCloseDeleter, error) { + var f readWriteSeekCloseDeleter + var err error + // Create file + if strings.HasPrefix(loc, "/") { + f = newOSWrapper(loc + filename) + } else { + scheme := strings.Split(loc, ":")[0] + // Write something to the file + f, err = s.testLocations[scheme].NewFile(filename) + if err != nil { + return nil, err + } + } + if existsBefore { + _, err = f.Write([]byte("some text")) + if err != nil { + return nil, err + } + err = f.Close() + if err != nil { + return nil, err + } + } + + return f, nil +} + +func (s *ioTestSuite) teardownTestLocation(testPath string) { + if strings.HasPrefix(testPath, "/") { + err := os.RemoveAll(testPath) + s.Require().NoError(err) + } else { + scheme := strings.Split(testPath, ":")[0] + // Write something to the file + loc := s.testLocations[scheme] + files, err := loc.List() + s.Require().NoError(err) + for _, file := range files { + err := loc.DeleteFile(file) + s.Require().NoError(err) + } + } +} + +func TestIOTestSuite(t *testing.T) { + suite.Run(t, new(ioTestSuite)) +} diff --git a/testcontainers/localstack.go b/testcontainers/localstack.go new file mode 100644 index 00000000..6968994b --- /dev/null +++ b/testcontainers/localstack.go @@ -0,0 +1,51 @@ +package testcontainers + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/localstack" + + "github.com/c2fo/vfs/v7/backend" + "github.com/c2fo/vfs/v7/backend/s3" +) + +const ( + localStackPort = "4566/tcp" + localStackRegion = "dummy" + localStackKey = "dummy" + localStackSecret = "dummy" +) + +func registerLocalStack(t *testing.T) string { + ctx := context.Background() + is := require.New(t) + + ctr, err := localstack.Run(ctx, "localstack/localstack:latest", testcontainers.WithName("vfs-localstack")) + testcontainers.CleanupContainer(t, ctr) + is.NoError(err) + + ep, err := ctr.PortEndpoint(ctx, localStackPort, "http") + is.NoError(err) + + cfg, err := config.LoadDefaultConfig(ctx) + is.NoError(err) + + cli := awss3.NewFromConfig(cfg, func(opts *awss3.Options) { + opts.Region = localStackRegion + opts.UsePathStyle = true + opts.BaseEndpoint = aws.String(ep) + opts.Credentials = credentials.NewStaticCredentialsProvider(localStackKey, localStackSecret, "") + }) + _, err = cli.CreateBucket(ctx, &awss3.CreateBucketInput{Bucket: aws.String("localstack")}) + is.NoError(err) + + backend.Register("s3://localstack/", s3.NewFileSystem(s3.WithClient(cli))) + return "s3://localstack/" +} diff --git a/testcontainers/minio.go b/testcontainers/minio.go new file mode 100644 index 00000000..54b2c7c1 --- /dev/null +++ b/testcontainers/minio.go @@ -0,0 +1,46 @@ +package testcontainers + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/minio" + + "github.com/c2fo/vfs/v7/backend" + "github.com/c2fo/vfs/v7/backend/s3" +) + +const minioRegion = "dummy" + +func registerMinio(t *testing.T) string { + ctx := context.Background() + is := require.New(t) + + ctr, err := minio.Run(ctx, "minio/minio:latest", testcontainers.WithName("vfs-minio")) + testcontainers.CleanupContainer(t, ctr) + is.NoError(err) + + ep, err := ctr.ConnectionString(ctx) + is.NoError(err) + + cfg, err := config.LoadDefaultConfig(ctx) + is.NoError(err) + + cli := awss3.NewFromConfig(cfg, func(opts *awss3.Options) { + opts.Region = minioRegion + opts.UsePathStyle = true + opts.BaseEndpoint = aws.String("http://" + ep) + opts.Credentials = credentials.NewStaticCredentialsProvider(ctr.Username, ctr.Password, "") + }) + _, err = cli.CreateBucket(ctx, &awss3.CreateBucketInput{Bucket: aws.String("miniobucket")}) + is.NoError(err) + + backend.Register("s3://miniobucket/", s3.NewFileSystem(s3.WithClient(cli), s3.WithOptions(s3.Options{DisableServerSideEncryption: true}))) + return "s3://miniobucket/" +} diff --git a/testcontainers/vsftpd.go b/testcontainers/vsftpd.go new file mode 100644 index 00000000..c0b7b537 --- /dev/null +++ b/testcontainers/vsftpd.go @@ -0,0 +1,48 @@ +package testcontainers + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/c2fo/vfs/v7/backend" + "github.com/c2fo/vfs/v7/backend/ftp" +) + +const ( + vsftpdPort = "21/tcp" + vsftpdPassword = "dummy" +) + +func registerVSFTPD(t *testing.T) string { + ctx := context.Background() + is := require.New(t) + + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Name: "vfs-vsftpd", + Image: "fauria/vsftpd:latest", + ExposedPorts: []string{"21", "21100-21110:21100-21110"}, + Env: map[string]string{"FTP_PASS": vsftpdPassword}, + WaitingFor: wait.ForListeningPort(vsftpdPort), + }, + Started: true, + } + ctr, err := testcontainers.GenericContainer(ctx, req) + testcontainers.CleanupContainer(t, ctr) + is.NoError(err) + + host, err := ctr.Host(ctx) + is.NoError(err) + + port, err := ctr.MappedPort(ctx, vsftpdPort) + is.NoError(err) + + authority := fmt.Sprintf("ftp://admin@%s:%s/", host, port.Port()) + backend.Register(authority, ftp.NewFileSystem(ftp.WithOptions(ftp.Options{Password: vsftpdPassword}))) + return authority +} From c9066ee493c2ecc6bf1da201dbd98aa3e72d7e7c Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Tue, 27 Jan 2026 14:16:18 +1100 Subject: [PATCH 2/7] Merge recent conformance changes --- testcontainers/azurite.go | 1 + testcontainers/backend_integration_test.go | 829 +-------------------- testcontainers/conformance.go | 643 ++++++++++++++++ testcontainers/doc.go | 4 +- testcontainers/io_conformance.go | 349 +++++++++ testcontainers/io_integration_test.go | 488 +----------- 6 files changed, 1045 insertions(+), 1269 deletions(-) create mode 100644 testcontainers/conformance.go create mode 100644 testcontainers/io_conformance.go diff --git a/testcontainers/azurite.go b/testcontainers/azurite.go index 7067bf7b..cbd709cb 100644 --- a/testcontainers/azurite.go +++ b/testcontainers/azurite.go @@ -21,6 +21,7 @@ func registerAzurite(t *testing.T) string { ctr, err := azurite.Run(ctx, "mcr.microsoft.com/azure-storage/azurite:latest", testcontainers.WithName("vfs-azurite"), azurite.WithEnabledServices(azurite.BlobService), + testcontainers.WithCmdArgs("--skipApiVersionCheck"), ) testcontainers.CleanupContainer(t, ctr) is.NoError(err) diff --git a/testcontainers/backend_integration_test.go b/testcontainers/backend_integration_test.go index c1bc0907..e1c9c4dd 100644 --- a/testcontainers/backend_integration_test.go +++ b/testcontainers/backend_integration_test.go @@ -2,22 +2,14 @@ package testcontainers import ( "fmt" - "io" - "net/url" "os" - "path" "path/filepath" - "regexp" - "strconv" - "strings" "sync" "testing" - "time" "github.com/stretchr/testify/suite" "github.com/c2fo/vfs/v7" - "github.com/c2fo/vfs/v7/utils" "github.com/c2fo/vfs/v7/vfssimple" ) @@ -26,10 +18,6 @@ type vfsTestSuite struct { testLocations map[string]vfs.Location } -func buildExpectedURI(fs vfs.FileSystem, authorityStr, p string) string { - return fmt.Sprintf("%s://%s%s", fs.Scheme(), authorityStr, p) -} - func (s *vfsTestSuite) SetupSuite() { registers := []func(*testing.T) string{ registerMem, @@ -44,18 +32,30 @@ func (s *vfsTestSuite) SetupSuite() { uris := make([]string, len(registers)) var wg sync.WaitGroup for i := range registers { - wg.Add(1) - go func() { - uris[i] = registers[i](s.T()) - wg.Done() - }() + wg.Go(func() { uris[i] = registers[i](s.T()) }) } wg.Wait() s.testLocations = make(map[string]vfs.Location) - for _, u := range uris { - l, err := vfssimple.NewLocation(u) + for _, loc := range uris { + l, err := vfssimple.NewLocation(loc) s.Require().NoError(err) + + // For file:// locations, ensure directory exists + if l.FileSystem().Scheme() == "file" { + exists, err := l.Exists() + if err != nil { + panic(err) + } + if !exists { + err := os.Mkdir(l.Path(), 0750) + if err != nil { + panic(err) + } + } + } + + // Store location by scheme - no type assertion needed s.testLocations[l.FileSystem().Scheme()] = l } } @@ -68,794 +68,21 @@ func registerOS(t *testing.T) string { return fmt.Sprintf("file://%s/", filepath.ToSlash(t.TempDir())) } -// Test Scheme +// TestScheme runs conformance tests for each configured backend func (s *vfsTestSuite) TestScheme() { for scheme, location := range s.testLocations { - s.Run(scheme, func() { - fmt.Printf("************** TESTING scheme: %s **************\n", scheme) - s.FileSystem(location) - s.Location(location) - s.File(location) - }) - } -} - -// Test FileSystem -func (s *vfsTestSuite) FileSystem(baseLoc vfs.Location) { - fmt.Println("****** testing vfs.FileSystem ******") + fmt.Printf("************** TESTING scheme: %s **************\n", scheme) - // setup FileSystem - fs := baseLoc.FileSystem() - // NewFile initializes a File on the specified authority string at path 'absFilePath'. - // - // * Accepts authority and an absolute file path. - // * Upon success, a vfs.File, representing the file's new path (location path + file relative path), will be returned. - // * On error, nil is returned for the file. - // * Note that not all file systems will have an "authority" and will therefore be "": - // file:///path/to/file has an authority of "" and name /path/to/file - // whereas - // s3://mybucket/path/to/file has an authority of "mybucket and name /path/to/file - // results in /tmp/dir1/newerdir/file.txt for the final vfs.File path. - // * The file may or may not already exist. - filepaths := map[string]bool{ - "/path/to/file.txt": true, - "/path/./to/file.txt": true, - "/path/../to/file.txt": true, - "path/to/file.txt": false, - "./path/to/file.txt": false, - "../path/to/": false, - "/path/to/": false, - "": false, - } - for name, validates := range filepaths { - file, err := fs.NewFile(baseLoc.Authority().String(), name) - if validates { - s.Require().NoError(err, "there should be no error") - expected := buildExpectedURI(fs, baseLoc.Authority().String(), path.Clean(name)) - s.Equal(expected, file.URI(), "uri's should match") - } else { - s.Require().Error(err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name) + // Determine conformance options based on scheme + opts := ConformanceOptions{ + SkipFTPSpecificTests: scheme == "ftp", } - } - - // NewLocation initializes a Location on the specified authority with the given path. - // - // * Accepts authority and an absolute location path. - // * The file may or may not already exist. Note that on key-store file systems like S3 or GCS, paths never truly exist. - // * On error, nil is returned for the location. - // - // See NewFile for note on authority. - locpaths := map[string]bool{ - "/path/to/": true, - "/path/./to/": true, - "/path/../to/": true, - "path/to/": false, - "./path/to/": false, - "../path/to/": false, - "/path/to/file.txt": false, - "": false, - } - for name, validates := range locpaths { - loc, err := fs.NewLocation(baseLoc.Authority().String(), name) - if validates { - s.Require().NoError(err, "there should be no error") - expected := buildExpectedURI(fs, baseLoc.Authority().String(), utils.EnsureTrailingSlash(path.Clean(name))) - s.Equal(expected, loc.URI(), "uri's should match") - } else { - s.Require().Error(err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name) - } - } -} - -// Test Location -func (s *vfsTestSuite) Location(baseLoc vfs.Location) { - fmt.Println("****** testing vfs.Location ******") - - srcLoc, err := baseLoc.NewLocation("locTestSrc/") - s.Require().NoError(err, "there should be no error") - defer func() { - // clean up srcLoc after test for OS - if srcLoc.FileSystem().Scheme() == "file" { - exists, err := srcLoc.Exists() - s.Require().NoError(err) - if exists { - s.Require().NoError(os.RemoveAll(srcLoc.Path()), "failed to clean up location test srcLoc") - } - } - }() - - // NewLocation is an initializer for a new Location relative to the existing one. - // - // Given location: - // loc := fs.NewLocation(:s3://mybucket/some/path/to/") - // calling: - // newLoc := loc.NewLocation("../../") - // would return a new vfs.Location representing: - // s3://mybucket/some/ - // - // * Accepts a relative location path. - locpaths := map[string]bool{ - "/path/to/": false, - "/path/./to/": false, - "/path/../to/": false, - "path/to/": true, - "./path/to/": true, - "../path/to/": true, - "/path/to/file.txt": false, - "": false, - } - for name, validates := range locpaths { - loc, err := srcLoc.NewLocation(name) - if validates { - s.Require().NoError(err, "there should be no error") - expected := buildExpectedURI(srcLoc.FileSystem(), baseLoc.Authority().String(), - utils.EnsureTrailingSlash(path.Clean(path.Join(srcLoc.Path(), name)))) - s.Equal(expected, loc.URI(), "uri's should match") - } else { - s.Require().Error(err, "should have validation error for scheme and name: %s : %s", srcLoc.FileSystem().Scheme(), name) - } - } - - // NewFile will instantiate a vfs.File instance at or relative to the current location's path. - // - // * Accepts a relative file path. - // * In the case of an error, nil is returned for the file. - // * Resultant File path will be the shortest path name equivalent of combining the Location path and relative path, if any. - // ie, /tmp/dir1/ as location and relFilePath "newdir/./../newerdir/file.txt" - // results in /tmp/dir1/newerdir/file.txt for the final vfs.File path. - // * Upon success, a vfs.File, representing the file's new path (location path + file relative path), will be returned. - // * The file may or may not already exist. - filepaths := map[string]bool{ - "/path/to/file.txt": false, - "/path/./to/file.txt": false, - "/path/../to/file.txt": false, - "path/to/file.txt": true, - "./path/to/file.txt": true, - "../path/to/": false, - "../path/to/file.txt": true, - "/path/to/": false, - "": false, - } - for name, validates := range filepaths { - file, err := srcLoc.NewFile(name) - if validates { - s.Require().NoError(err, "there should be no error") - expected := buildExpectedURI(srcLoc.FileSystem(), srcLoc.Authority().String(), path.Clean(path.Join(srcLoc.Path(), name))) - s.Equal(expected, file.URI(), "uri's should match") - } else { - s.Require().Error(err, "should have validation error for scheme and name: %s : +%s+", srcLoc.FileSystem().Scheme(), name) - } - } - - // ChangeDir updates the existing Location's path to the provided relative location path. - - // Given location: - // loc := fs.NewLocation("file:///some/path/to/") - // calling: - // loc.ChangeDir("../../") - // would update the current location instance to - // file:///some/. - // - // * ChangeDir accepts a relative location path. - - // setup test - cdTestLoc, err := srcLoc.NewLocation("chdirTest/") - s.Require().NoError(err) - - _, err = cdTestLoc.NewLocation("") - s.Require().Error(err, "empty string should error") - _, err = cdTestLoc.NewLocation("/home/") - s.Require().Error(err, "absolute path should error") - _, err = cdTestLoc.NewLocation("file.txt") - s.Require().Error(err, "file should error") - cdTestLoc, err = cdTestLoc.NewLocation("l1dir1/./l2dir1/../l2dir2/") - s.Require().NoError(err, "should be no error for relative path") - - // Path returns absolute location path, ie /some/path/to/. - // ==== Path() string - s.True(strings.HasSuffix(cdTestLoc.Path(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved") - s.True(strings.HasPrefix(cdTestLoc.Path(), "/"), "should start with slash (abs path)") - - // URI returns the fully qualified URI for the Location. IE, s3://bucket/some/path/ - // - // URI's for locations must always end with a separator character. - s.True(strings.HasSuffix(cdTestLoc.URI(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved") - prefix := cdTestLoc.FileSystem().Scheme() + "://" - s.True(strings.HasPrefix(cdTestLoc.URI(), prefix), "should start with schema and abs slash") - - /* Exists returns boolean if the location exists on the file system. Returns an error if any. - - TODO: ************************************************************************************************************* - note that Exists is not consistent among implementations. GCSs and S3 always return true if the bucket exist. - Fundamentally, why one wants to know if location exists is to know whether you're able to write there. But - this feels unintuitive. - ************************************************************************************************************* - - Consider: - - // CREATE LOCATION INSTANCE - loc, _ := vfssimple.NewLocation("scheme://vol/path/") - - // DO EXISTS CHECK ON LOCATION - if !loc.Exists() { - // CREATE LOCATION ON OS - } - - // CREATE FILE IN LOCATION AND DO WORK - myfile, _ := loc.NewFile("myfile.txt") - myfile.Write("write some text") - myfile.Close() - - Now consider if the context is os/sftp OR gcs/s3/mem. - - ==== Exists() (bool, error) - */ - exists, err := baseLoc.Exists() - s.Require().NoError(err) - s.True(exists, "baseLoc location exists check") - - // setup list tests - f1, err := srcLoc.NewFile("file1.txt") - s.Require().NoError(err) - _, err = f1.Write([]byte("this is a test file")) - s.Require().NoError(err) - s.Require().NoError(f1.Close()) - - f2, err := srcLoc.NewFile("file2.txt") - s.Require().NoError(err) - s.Require().NoError(f1.CopyToFile(f2)) - s.Require().NoError(f1.Close()) - - f3, err := srcLoc.NewFile("self.txt") - s.Require().NoError(err) - s.Require().NoError(f1.CopyToFile(f3)) - s.Require().NoError(f1.Close()) - - subLoc, err := srcLoc.NewLocation("somepath/") - s.Require().NoError(err) - - f4, err := subLoc.NewFile("that.txt") - s.Require().NoError(err) - s.Require().NoError(f1.CopyToFile(f4)) - s.Require().NoError(f1.Close()) - - // List returns a slice of strings representing the base names of the files found at the Location. - // - // * All implementations are expected to return ([]string{}, nil) in the case of a non-existent directory/prefix/location. - // * If the user cares about the distinction between an empty location and a non-existent one, Location.Exists() should - // be checked first. - // ==== List() ([]string, error) - - files, err := srcLoc.List() - s.Require().NoError(err) - s.Len(files, 3, "list srcLoc location") - - files, err = subLoc.List() - s.Require().NoError(err) - s.Len(files, 1, "list subLoc location") - s.Equal("that.txt", files[0], "returned basename") - - files, err = cdTestLoc.List() - s.Require().NoError(err) - s.Empty(files, "non-existent location") - - // ListByPrefix returns a slice of strings representing the base names of the files found in Location whose filenames - // match the given prefix. - // - // * All implementations are expected to return ([]string{}, nil) in the case of a non-existent directory/prefix/location. - // * "relative" prefixes are allowed, ie, ListByPrefix() from location "/some/path/" with prefix "to/somepattern" - // is the same as location "/some/path/to/" with prefix of "somepattern" - // * If the user cares about the distinction between an empty location and a non-existent one, Location.Exists() should - // be checked first. - // ==== ListByPrefix(prefix string) ([]string, error) - - files, err = srcLoc.ListByPrefix("file") - s.Require().NoError(err) - s.Len(files, 2, "list srcLoc location matching prefix") - - files, err = srcLoc.ListByPrefix("s") - s.Require().NoError(err) - s.Len(files, 1, "list srcLoc location") - s.Equal("self.txt", files[0], "returned only file basename, not subdir matching prefix") - - files, err = srcLoc.ListByPrefix("somepath/t") - s.Require().NoError(err) - s.Len(files, 1, "list 'somepath' location relative to srcLoc") - s.Equal("that.txt", files[0], "returned only file basename, using relative prefix") - - files, err = cdTestLoc.List() - s.Require().NoError(err) - s.Empty(files, "non-existent location") - - // ListByRegex returns a slice of strings representing the base names of the files found in the Location that matched the - // given regular expression. - // - // * All implementations are expected to return ([]string{}, nil) in the case of a non-existent directory/prefix/location. - // * If the user cares about the distinction between an empty location and a non-existent one, Location.Exists() should - // be checked first. - // ==== ListByRegex(regex *regexp.Regexp) ([]string, error) - - files, err = srcLoc.ListByRegex(regexp.MustCompile("^f")) - s.Require().NoError(err) - s.Len(files, 2, "list srcLoc location matching prefix") - - files, err = srcLoc.ListByRegex(regexp.MustCompile(`.txt$`)) - s.Require().NoError(err) - s.Len(files, 3, "list srcLoc location matching prefix") - - files, err = srcLoc.ListByRegex(regexp.MustCompile(`Z`)) - s.Require().NoError(err) - s.Empty(files, "list srcLoc location matching prefix") - - // DeleteFile deletes the file of the given name at the location. - // - // This is meant to be a short cut for instantiating a new file and calling delete on that, with all the necessary - // error handling overhead. - // - // * Accepts relative file path. - // - // ==== DeleteFile(fileName string) error - s.Require().NoError(srcLoc.DeleteFile(f1.Name()), "deleteFile file1") - s.Require().NoError(srcLoc.DeleteFile(f2.Name()), "deleteFile file2") - s.Require().NoError(srcLoc.DeleteFile(f3.Name()), "deleteFile self.txt") - s.Require().NoError(srcLoc.DeleteFile("somepath/that.txt"), "deleted relative path") - - // should error if file doesn't exist - s.Require().Error(srcLoc.DeleteFile(f1.Path()), "deleteFile trying to delete a file already deleted") -} - -// Test File -func (s *vfsTestSuite) File(baseLoc vfs.Location) { - fmt.Println("****** testing vfs.File ******") - srcLoc, err := baseLoc.NewLocation("fileTestSrc/") - s.Require().NoError(err) - defer func() { - // clean up srcLoc after test for OS - if srcLoc.FileSystem().Scheme() == "file" { - exists, err := srcLoc.Exists() - s.Require().NoError(err) - if exists { - s.Require().NoError(os.RemoveAll(srcLoc.Path()), "failed to clean up file test srcLoc") - } - } - }() - - // setup srcFile - srcFile, err := srcLoc.NewFile("srcFile.txt") - s.Require().NoError(err) - - /* - Location returns the vfs.Location for the File. - - Location() Location - */ - - /* - io.Writer - */ - sz, err := srcFile.Write([]byte("this is a test\n")) - s.Require().NoError(err) - s.Equal(15, sz) - sz, err = srcFile.Write([]byte("and more text")) - s.Require().NoError(err) - s.Equal(13, sz) - - /* - io.Closer - */ - err = srcFile.Close() - s.Require().NoError(err) - - /* - Exists returns boolean if the file exists on the file system. Also returns an error if any. - - Exists() (bool, error) - */ - exists, err := srcFile.Exists() - s.Require().NoError(err) - s.True(exists, "file exists") - - /* - Name returns the base name of the file path. For file:///some/path/to/file.txt, it would return file.txt - - Name() string - */ - s.Equal("srcFile.txt", srcFile.Name(), "name test") - - /* - Path returns absolute path (with leading slash) including filename, ie /some/path/to/file.txt - - Path() string - */ - s.Equal(path.Join(baseLoc.Path(), "fileTestSrc/srcFile.txt"), srcFile.Path(), "path test") - - /* - URI returns the fully qualified URI for the File. IE, s3://bucket/some/path/to/file.txt - - URI() string - */ - s.Equal(baseLoc.URI()+"fileTestSrc/srcFile.txt", srcFile.URI(), "uri test") - - /* - String() must be implemented to satisfy the stringer interface. This ends up simply calling URI(). - fmt.Stringer - */ - s.Equal(baseLoc.URI()+"fileTestSrc/srcFile.txt", srcFile.String(), "string(er) explicit test") - s.Equal(baseLoc.URI()+"fileTestSrc/srcFile.txt", fmt.Sprintf("%s", srcFile), "string(er) implicit test") //nolint:gocritic,staticcheck - - /* - Size returns the size of the file in bytes. - - Size() (uint64, error) - */ - b, err := srcFile.Size() - s.Require().NoError(err) - s.Equal(uint64(28), b) - - /* - LastModified returns the timestamp the file was last modified (as *time.Time). - - LastModified() (*time.Time, error) - */ - t, err := srcFile.LastModified() - s.Require().NoError(err) - s.IsType((*time.Time)(nil), t, "last modified returned *time.Time") - - /* - Exists returns boolean if the file exists on the file system. Also returns an error if any. - - Exists() (bool, error) - */ - exists, err = srcFile.Exists() - s.Require().NoError(err) - s.True(exists, "file exists") - - /* - io.Reader and io.Seeker - */ - str, err := io.ReadAll(srcFile) - s.Require().NoError(err) - s.Equal("this is a test\nand more text", string(str), "read was successful") - - offset, err := srcFile.Seek(3, 0) - s.Require().NoError(err) - s.Equal(int64(3), offset, "seek was successful") - - str, err = io.ReadAll(srcFile) - s.Require().NoError(err) - s.Equal("s is a test\nand more text", string(str), "read after seek") - err = srcFile.Close() - s.Require().NoError(err) - - for _, testLoc := range s.testLocations { - // setup dstLoc - dstLoc, err := testLoc.NewLocation("dstLoc/") - s.Require().NoError(err) - fmt.Printf("** location %s **\n", dstLoc) - if dstLoc.FileSystem().Scheme() == "file" { - s.T().Cleanup(func() { - // clean up dstLoc after test for OS - exists, err := dstLoc.Exists() - s.Require().NoError(err) - if exists { - s.Require().NoError(os.RemoveAll(dstLoc.Path()), "failed to clean up file test dstLoc") - } - }) - } - - // CopyToLocation will copy the current file to the provided location. - // - // * Upon success, a vfs.File, representing the file at the new location, will be returned. - // * In the case of an error, nil is returned for the file. - // * CopyToLocation should use native functions when possible within the same scheme. - // * If the file already exists at the location, the contents will be overwritten with the current file's contents. - _, err = srcFile.Seek(0, 0) - s.Require().NoError(err) - dst, err := srcFile.CopyToLocation(dstLoc) - s.Require().NoError(err) - exists, err := dst.Exists() - s.Require().NoError(err) - s.True(exists, "dst file should now exist") - exists, err = srcFile.Exists() - s.Require().NoError(err) - s.True(exists, "src file should still exist") - - // CopyToFile will copy the current file to the provided file instance. - // - // * In the case of an error, nil is returned for the file. - // * CopyToLocation should use native functions when possible within the same scheme. - // * If the file already exists, the contents will be overwritten with the current file's contents. - - // setup dstFile - dstFile1, err := dstLoc.NewFile("dstFile1.txt") - s.Require().NoError(err) - exists, err = dstFile1.Exists() - s.Require().NoError(err) - s.False(exists, "dstFile1 file should not yet exist") - _, err = srcFile.Seek(0, 0) - s.Require().NoError(err) - err = srcFile.CopyToFile(dstFile1) - s.Require().NoError(err) - exists, err = dstFile1.Exists() - s.Require().NoError(err) - s.True(exists, "dstFile1 file should now exist") - exists, err = srcFile.Exists() - s.Require().NoError(err) - s.True(exists, "src file should still exist") - - /* - io.Copy - */ - // create a local copy from srcFile with io.Copy - copyFile1, err := srcLoc.NewFile("copyFile1.txt") - s.Require().NoError(err) - // should not exist - exists, err = copyFile1.Exists() - s.Require().NoError(err) - s.False(exists, "copyFile1 should not yet exist locally") - // do copy - // skip this test for ftp files - buffer := make([]byte, utils.TouchCopyMinBufferSize) - - if srcLoc.FileSystem().Scheme() != "ftp" { - _, err = srcFile.Seek(0, 0) - s.Require().NoError(err) - b1, err := io.CopyBuffer(copyFile1, srcFile, buffer) - s.Require().NoError(err) - s.Equal(int64(28), b1) - err = copyFile1.Close() - s.Require().NoError(err) - - // should now exist - exists, err = copyFile1.Exists() - s.Require().NoError(err) - s.True(exists, "%s should now exist locally", copyFile1) - err = copyFile1.Close() - s.Require().NoError(err) - } else { - // else still have to ensure copyFile1 exists for later tests - err = copyFile1.Touch() - s.Require().NoError(err) - } - - // create another local copy from srcFile with io.Copy - copyFile2, err := srcLoc.NewFile("copyFile2.txt") - s.Require().NoError(err) - // should not exist - exists, err = copyFile2.Exists() - s.Require().NoError(err) - s.False(exists, "copyFile2 should not yet exist locally") - // do copy - // skip this test for ftp files - if srcLoc.FileSystem().Scheme() != "ftp" { - _, err = srcFile.Seek(0, 0) - s.Require().NoError(err) - buffer = make([]byte, utils.TouchCopyMinBufferSize) - b2, err := io.CopyBuffer(copyFile2, srcFile, buffer) - s.Require().NoError(err) - s.Equal(int64(28), b2) - - err = copyFile2.Close() - s.Require().NoError(err) - // should now exist - exists, err = copyFile2.Exists() - s.Require().NoError(err) - s.True(exists, "copyFile2 should now exist locally") - err = copyFile2.Close() - s.Require().NoError(err) - } else { - // else still have to ensure copyFile1 exists for later tests - err = copyFile2.Touch() - s.Require().NoError(err) - } - - // MoveToLocation will move the current file to the provided location. - // - // * If the file already exists at the location, the contents will be overwritten with the current file's contents. - // * If the location does not exist, an attempt will be made to create it. - // * Upon success, a vfs.File, representing the file at the new location, will be returned. - // * In the case of an error, nil is returned for the file. - // * When moving within the same Scheme, native move/rename should be used where possible. - // * If the file already exists, the contents will be overwritten with the current file's contents. - fileForNew, err := srcLoc.NewFile("fileForNew.txt") - s.Require().NoError(err) - - // skip this test for ftp files - if srcLoc.FileSystem().Scheme() != "ftp" { - _, err = srcFile.Seek(0, 0) - s.Require().NoError(err) - buffer = make([]byte, utils.TouchCopyMinBufferSize) - _, err = io.CopyBuffer(fileForNew, srcFile, buffer) - s.Require().NoError(err) - err = fileForNew.Close() - s.Require().NoError(err) - - newLoc, err := dstLoc.NewLocation("doesnotexist/") - s.Require().NoError(err) - dstCopyNew, err := fileForNew.MoveToLocation(newLoc) - s.Require().NoError(err) - exists, err = dstCopyNew.Exists() - s.Require().NoError(err) - s.True(exists) - s.Require().NoError(dstCopyNew.Delete()) // clean up file - - s.Require().NoError(srcFile.Close()) - } - - dstCopy1, err := copyFile1.MoveToLocation(dstLoc) - s.Require().NoError(err) - // destination file should now exist - exists, err = dstCopy1.Exists() - s.Require().NoError(err) - s.True(exists, "dstCopy1 file should now exist") - // local copy should no longer exist - exists, err = copyFile1.Exists() - s.Require().NoError(err) - s.False(exists, "copyFile1 should no longer exist locally") - - // MoveToFile will move the current file to the provided file instance. - // - // * If the file already exists, the contents will be overwritten with the current file's contents. - // * The current instance of the file will be removed. - dstCopy2, err := dstLoc.NewFile("dstFile2.txt") - s.Require().NoError(err) - // destination file should not exist - exists, err = dstCopy2.Exists() - s.Require().NoError(err) - s.False(exists, "dstCopy2 file should not yet exist") - // do move file - err = copyFile2.MoveToFile(dstCopy2) - s.Require().NoError(err) - // local copy should no longer exist - exists, err = copyFile2.Exists() - s.Require().NoError(err) - s.False(exists, "copyFile2 should no longer exist locally") - // destination file should now exist - exists, err = dstCopy2.Exists() - s.Require().NoError(err) - s.True(exists, "dstCopy2 file should now exist") - - // clean up files - err = dst.Delete() - s.Require().NoError(err) - err = dstFile1.Delete() - s.Require().NoError(err) - err = dstCopy1.Delete() - s.Require().NoError(err) - err = dstCopy2.Delete() - s.Require().NoError(err) - - // ensure that MoveToFile() works for files with spaces - type moveSpaceTest struct { - Path, Filename string - } - tests := []moveSpaceTest{ - {Path: "file", Filename: "has space.txt"}, - {Path: "file", Filename: "has%20encodedSpace.txt"}, - {Path: "path has", Filename: "space.txt"}, - {Path: "path%20has", Filename: "encodedSpace.txt"}, - } - - for i, test := range tests { - s.Run(strconv.Itoa(i), func() { - // setup src - srcSpaces, err := srcLoc.NewFile(path.Join(test.Path, test.Filename)) - s.Require().NoError(err) - b, err := srcSpaces.Write([]byte("something")) - s.Require().NoError(err) - s.Equal(9, b, "byte count is correct") - err = srcSpaces.Close() - s.Require().NoError(err) - - testDestLoc, err := dstLoc.NewLocation(test.Path + "/") - s.Require().NoError(err) - - dstSpaces, err := srcSpaces.MoveToLocation(testDestLoc) - s.Require().NoError(err) - exists, err := dstSpaces.Exists() - s.Require().NoError(err) - s.True(exists, "dstSpaces should now exist") - exists, err = srcSpaces.Exists() - s.Require().NoError(err) - s.False(exists, "srcSpaces should no longer exist") - s.True( - strings.HasSuffix(dstSpaces.URI(), path.Join(test.Path, test.Filename)) || - strings.HasSuffix(dstSpaces.URI(), path.Join(url.PathEscape(test.Path), url.PathEscape(test.Filename))), - "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename), - ) - - newSrcSpaces, err := dstSpaces.MoveToLocation(srcSpaces.Location()) - s.Require().NoError(err) - exists, err = newSrcSpaces.Exists() - s.Require().NoError(err) - s.True(exists, "newSrcSpaces should now exist") - exists, err = dstSpaces.Exists() - s.Require().NoError(err) - s.False(exists, "dstSpaces should no longer exist") - hasSuffix := strings.HasSuffix(newSrcSpaces.URI(), path.Join(test.Path, test.Filename)) || - strings.HasSuffix(newSrcSpaces.URI(), path.Join(url.PathEscape(test.Path), url.PathEscape(test.Filename))) - s.True(hasSuffix, "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename)) - - err = newSrcSpaces.Delete() - s.Require().NoError(err) - exists, err = newSrcSpaces.Exists() - s.Require().NoError(err) - s.False(exists, "newSrcSpaces should now exist") - }) - } - } - - // Touch creates a zero-length file on the vfs.File if no File exists. Update File's last modified timestamp. - // Returns error if unable to touch File. - - touchedFile, err := srcLoc.NewFile("touch.txt") - s.Require().NoError(err) - defer func() { _ = touchedFile.Delete() }() - exists, err = touchedFile.Exists() - s.Require().NoError(err) - s.False(exists, "%s shouldn't yet exist", touchedFile) - - err = touchedFile.Touch() - s.Require().NoError(err) - exists, err = touchedFile.Exists() - s.Require().NoError(err) - s.True(exists, "%s now exists", touchedFile) - - size, err := touchedFile.Size() - s.Require().NoError(err) - s.Zero(size, "%s should be empty", touchedFile) - - // capture last modified - modified, err := touchedFile.LastModified() - s.Require().NoError(err) - modifiedDeRef := *modified - // wait for eventual consistency - if baseLoc.FileSystem().Scheme() == "ftp" { - time.Sleep(time.Duration(61-time.Now().Second()) * time.Second) - } else { - time.Sleep(time.Second) + // Run the exported conformance tests + s.Run(scheme, func() { + RunConformanceTests(s.T(), location, opts) + }) } - err = touchedFile.Touch() - s.Require().NoError(err) - newModified, err := touchedFile.LastModified() - s.Require().NoError(err) - s.Greater(newModified.UnixNano(), modifiedDeRef.UnixNano(), "touch updated modified date for %s", touchedFile) - - /* - Delete unlinks the File on the file system. - - Delete() error - */ - err = srcFile.Delete() - s.Require().NoError(err) - exists, err = srcFile.Exists() - s.Require().NoError(err) - s.False(exists, "file no longer exists") - - // The following blocks test that an error is thrown when these operations are called on a non-existent file - srcFile, err = srcLoc.NewFile("thisFileDoesNotExist") - s.Require().NoError(err, "unexpected error creating file") - - exists, err = srcFile.Exists() - s.Require().NoError(err) - s.False(exists, "file should not exist") - - size, err = srcFile.Size() - s.Require().Error(err, "expected error because file does not exist") - s.Zero(size) - - _, err = srcFile.LastModified() - s.Require().Error(err, "expected error because file does not exist") - - seeked, err := srcFile.Seek(-1, 2) - s.Require().Error(err, "expected error because file does not exist") - s.Zero(seeked) - - _, err = srcFile.Read(make([]byte, 1)) - s.Require().Error(err, "expected error because file does not exist") - - // end existence tests } func TestVFS(t *testing.T) { diff --git a/testcontainers/conformance.go b/testcontainers/conformance.go new file mode 100644 index 00000000..947d43cb --- /dev/null +++ b/testcontainers/conformance.go @@ -0,0 +1,643 @@ +// Package testcontainers provides conformance tests for VFS backend implementations. +// +// These tests can be imported by any backend (core or contrib) to verify +// correct implementation of the vfs.FileSystem, vfs.Location, and vfs.File interfaces. +// +// Usage: +// +// //go:build vfsintegration +// +// package mybackend +// +// import ( +// "testing" +// "github.com/c2fo/vfs/v7/testcontainers" +// ) +// +// func TestConformance(t *testing.T) { +// fs := NewFileSystem(/* options */) +// loc, _ := fs.NewLocation("", "/test-path/") +// testcontainers.RunConformanceTests(t, loc) +// } +package testcontainers + +import ( + "fmt" + "io" + "os" + "path" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/c2fo/vfs/v7" + "github.com/c2fo/vfs/v7/utils" +) + +// ConformanceOptions configures conformance test behavior +type ConformanceOptions struct { + // SkipTouchTimestampTest skips the Touch timestamp update assertion. + // Some backends (e.g., Dropbox) may not update timestamps when content is unchanged. + SkipTouchTimestampTest bool + + // SkipFTPSpecificTests skips tests that don't work well with FTP + SkipFTPSpecificTests bool +} + +// RunConformanceTests runs all conformance tests against the provided location. +// This is the main entry point for backend conformance testing. +func RunConformanceTests(t *testing.T, baseLoc vfs.Location, opts ...ConformanceOptions) { + t.Helper() + opt := ConformanceOptions{} + if len(opts) > 0 { + opt = opts[0] + } + + t.Run("FileSystem", func(t *testing.T) { + RunFileSystemTests(t, baseLoc) + }) + + t.Run("Location", func(t *testing.T) { + RunLocationTests(t, baseLoc) + }) + + t.Run("File", func(t *testing.T) { + RunFileTests(t, baseLoc, opt) + }) +} + +// RunFileSystemTests tests vfs.FileSystem interface conformance +func RunFileSystemTests(t *testing.T, baseLoc vfs.Location) { + t.Helper() + fs := baseLoc.FileSystem() + + // NewFile initializes a File on the specified Authority string at path 'absFilePath'. + filepaths := map[string]bool{ + "/path/to/file.txt": true, + "/path/./to/file.txt": true, + "/path/../to/file.txt": true, + "path/to/file.txt": false, + "./path/to/file.txt": false, + "../path/to/": false, + "/path/to/": false, + "": false, + } + for name, validates := range filepaths { + file, err := fs.NewFile(baseLoc.Authority().String(), name) + if validates { + require.NoError(t, err, "there should be no error") + expected := buildExpectedURI(fs, baseLoc.Authority().String(), path.Clean(name)) + assert.Equal(t, expected, file.URI(), "uri's should match") + } else { + require.Error(t, err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name) + } + } + + // NewLocation initializes a Location on the specified authority with the given path. + locpaths := map[string]bool{ + "/path/to/": true, + "/path/./to/": true, + "/path/../to/": true, + "path/to/": false, + "./path/to/": false, + "../path/to/": false, + "/path/to/file.txt": false, + "": false, + } + for name, validates := range locpaths { + loc, err := fs.NewLocation(baseLoc.Authority().String(), name) + if validates { + require.NoError(t, err, "there should be no error") + expected := buildExpectedURI(fs, baseLoc.Authority().String(), utils.EnsureTrailingSlash(path.Clean(name))) + assert.Equal(t, expected, loc.URI(), "uri's should match") + } else { + require.Error(t, err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name) + } + } +} + +// RunLocationTests tests vfs.Location interface conformance +func RunLocationTests(t *testing.T, baseLoc vfs.Location) { + t.Helper() + + srcLoc, err := baseLoc.NewLocation("locTestSrc/") + require.NoError(t, err, "there should be no error") + defer func() { + // clean up srcLoc after test for OS + if srcLoc.FileSystem().Scheme() == "file" { + exists, err := srcLoc.Exists() + require.NoError(t, err) + if exists { + require.NoError(t, os.RemoveAll(srcLoc.Path()), "failed to clean up location test srcLoc") + } + } + }() + + // NewLocation is an initializer for a new Location relative to the existing one. + locpaths := map[string]bool{ + "/path/to/": false, + "/path/./to/": false, + "/path/../to/": false, + "path/to/": true, + "./path/to/": true, + "../path/to/": true, + "/path/to/file.txt": false, + "": false, + } + for name, validates := range locpaths { + loc, err := srcLoc.NewLocation(name) + if validates { + require.NoError(t, err, "there should be no error") + expected := buildExpectedURI(srcLoc.FileSystem(), baseLoc.Authority().String(), + utils.EnsureTrailingSlash(path.Clean(path.Join(srcLoc.Path(), name)))) + assert.Equal(t, expected, loc.URI(), "uri's should match") + } else { + require.Error(t, err, "should have validation error for scheme and name: %s : %s", srcLoc.FileSystem().Scheme(), name) + } + } + + // NewFile will instantiate a vfs.File instance at or relative to the current location's path. + filepaths := map[string]bool{ + "/path/to/file.txt": false, + "/path/./to/file.txt": false, + "/path/../to/file.txt": false, + "path/to/file.txt": true, + "./path/to/file.txt": true, + "../path/to/": false, + "../path/to/file.txt": true, + "/path/to/": false, + "": false, + } + for name, validates := range filepaths { + file, err := srcLoc.NewFile(name) + if validates { + require.NoError(t, err, "there should be no error") + expected := buildExpectedURI(srcLoc.FileSystem(), srcLoc.Authority().String(), path.Clean(path.Join(srcLoc.Path(), name))) + assert.Equal(t, expected, file.URI(), "uri's should match") + } else { + require.Error(t, err, "should have validation error for scheme and name: %s : +%s+", srcLoc.FileSystem().Scheme(), name) + } + } + + // ChangeDir / NewLocation tests + cdTestLoc, err := srcLoc.NewLocation("chdirTest/") + require.NoError(t, err) + + _, err = cdTestLoc.NewLocation("") + require.Error(t, err, "empty string should error") + _, err = cdTestLoc.NewLocation("/home/") + require.Error(t, err, "absolute path should error") + _, err = cdTestLoc.NewLocation("file.txt") + require.Error(t, err, "file should error") + cdTestLoc, err = cdTestLoc.NewLocation("l1dir1/./l2dir1/../l2dir2/") + require.NoError(t, err, "should be no error for relative path") + + // Path returns absolute location path + assert.True(t, strings.HasSuffix(cdTestLoc.Path(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved") + assert.True(t, strings.HasPrefix(cdTestLoc.Path(), "/"), "should start with slash (abs path)") + + // URI returns the fully qualified URI for the Location + assert.True(t, strings.HasSuffix(cdTestLoc.URI(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved") + prefix := cdTestLoc.FileSystem().Scheme() + "://" + assert.True(t, strings.HasPrefix(cdTestLoc.URI(), prefix), "should start with schema and abs slash") + + // Exists + exists, err := baseLoc.Exists() + require.NoError(t, err) + assert.True(t, exists, "baseLoc location exists check") + + // setup list tests + f1, err := srcLoc.NewFile("file1.txt") + require.NoError(t, err) + _, err = f1.Write([]byte("this is a test file")) + require.NoError(t, err) + require.NoError(t, f1.Close()) + + f2, err := srcLoc.NewFile("file2.txt") + require.NoError(t, err) + require.NoError(t, f1.CopyToFile(f2)) + require.NoError(t, f1.Close()) + + f3, err := srcLoc.NewFile("self.txt") + require.NoError(t, err) + require.NoError(t, f1.CopyToFile(f3)) + require.NoError(t, f1.Close()) + + subLoc, err := srcLoc.NewLocation("somepath/") + require.NoError(t, err) + + f4, err := subLoc.NewFile("that.txt") + require.NoError(t, err) + require.NoError(t, f1.CopyToFile(f4)) + require.NoError(t, f1.Close()) + + // List + files, err := srcLoc.List() + require.NoError(t, err) + assert.Len(t, files, 3, "list srcLoc location") + + files, err = subLoc.List() + require.NoError(t, err) + assert.Len(t, files, 1, "list subLoc location") + assert.Equal(t, "that.txt", files[0], "returned basename") + + files, err = cdTestLoc.List() + require.NoError(t, err) + assert.Empty(t, files, "non-existent location") + + // ListByPrefix + files, err = srcLoc.ListByPrefix("file") + require.NoError(t, err) + assert.Len(t, files, 2, "list srcLoc location matching prefix") + + files, err = srcLoc.ListByPrefix("s") + require.NoError(t, err) + assert.Len(t, files, 1, "list srcLoc location") + assert.Equal(t, "self.txt", files[0], "returned only file basename, not subdir matching prefix") + + files, err = srcLoc.ListByPrefix("somepath/t") + require.NoError(t, err) + assert.Len(t, files, 1, "list 'somepath' location relative to srcLoc") + assert.Equal(t, "that.txt", files[0], "returned only file basename, using relative prefix") + + files, err = cdTestLoc.List() + require.NoError(t, err) + assert.Empty(t, files, "non-existent location") + + // ListByRegex + files, err = srcLoc.ListByRegex(regexp.MustCompile("^f")) + require.NoError(t, err) + assert.Len(t, files, 2, "list srcLoc location matching prefix") + + files, err = srcLoc.ListByRegex(regexp.MustCompile(`.txt$`)) + require.NoError(t, err) + assert.Len(t, files, 3, "list srcLoc location matching prefix") + + files, err = srcLoc.ListByRegex(regexp.MustCompile(`Z`)) + require.NoError(t, err) + assert.Empty(t, files, "list srcLoc location matching prefix") + + // DeleteFile + require.NoError(t, srcLoc.DeleteFile(f1.Name()), "deleteFile file1") + require.NoError(t, srcLoc.DeleteFile(f2.Name()), "deleteFile file2") + require.NoError(t, srcLoc.DeleteFile(f3.Name()), "deleteFile self.txt") + require.NoError(t, srcLoc.DeleteFile("somepath/that.txt"), "deleted relative path") + + // should error if file doesn't exist + require.Error(t, srcLoc.DeleteFile(f1.Path()), "deleteFile trying to delete a file already deleted") +} + +// RunFileTests tests vfs.File interface conformance +func RunFileTests(t *testing.T, baseLoc vfs.Location, opts ConformanceOptions) { + t.Helper() + + srcLoc, err := baseLoc.NewLocation("fileTestSrc/") + require.NoError(t, err) + defer func() { + // clean up srcLoc after test for OS + if srcLoc.FileSystem().Scheme() == "file" { + exists, err := srcLoc.Exists() + require.NoError(t, err) + if exists { + require.NoError(t, os.RemoveAll(srcLoc.Path()), "failed to clean up file test srcLoc") + } + } + }() + + // setup srcFile + srcFile, err := srcLoc.NewFile("srcFile.txt") + require.NoError(t, err) + + // io.Writer + sz, err := srcFile.Write([]byte("this is a test\n")) + require.NoError(t, err) + assert.Equal(t, 15, sz) + sz, err = srcFile.Write([]byte("and more text")) + require.NoError(t, err) + assert.Equal(t, 13, sz) + + // io.Closer + err = srcFile.Close() + require.NoError(t, err) + + // Exists + exists, err := srcFile.Exists() + require.NoError(t, err) + assert.True(t, exists, "file exists") + + // Name + assert.Equal(t, "srcFile.txt", srcFile.Name(), "name test") + + // Path + assert.Equal(t, path.Join(baseLoc.Path(), "fileTestSrc/srcFile.txt"), srcFile.Path(), "path test") + + // URI + assert.Equal(t, baseLoc.URI()+"fileTestSrc/srcFile.txt", srcFile.URI(), "uri test") + + // fmt.Stringer + assert.Equal(t, baseLoc.URI()+"fileTestSrc/srcFile.txt", srcFile.String(), "string(er) explicit test") + var stringer fmt.Stringer = srcFile + assert.Equal(t, baseLoc.URI()+"fileTestSrc/srcFile.txt", stringer.String(), "string(er) implicit test") + + // Size + b, err := srcFile.Size() + require.NoError(t, err) + assert.Equal(t, uint64(28), b) + + // LastModified + tm, err := srcFile.LastModified() + require.NoError(t, err) + assert.IsType(t, (*time.Time)(nil), tm, "last modified returned *time.Time") + + // Exists (again) + exists, err = srcFile.Exists() + require.NoError(t, err) + assert.True(t, exists, "file exists") + + // io.Reader and io.Seeker + str, err := io.ReadAll(srcFile) + require.NoError(t, err) + assert.Equal(t, "this is a test\nand more text", string(str), "read was successful") + + offset, err := srcFile.Seek(3, 0) + require.NoError(t, err) + assert.Equal(t, int64(3), offset, "seek was successful") + + str, err = io.ReadAll(srcFile) + require.NoError(t, err) + assert.Equal(t, "s is a test\nand more text", string(str), "read after seek") + err = srcFile.Close() + require.NoError(t, err) + + // CopyToLocation - test copying to same location + dstLoc, err := baseLoc.NewLocation("dstLoc/") + require.NoError(t, err) + if dstLoc.FileSystem().Scheme() == "file" { + t.Cleanup(func() { + exists, err := dstLoc.Exists() + require.NoError(t, err) + if exists { + require.NoError(t, os.RemoveAll(dstLoc.Path()), "failed to clean up file test dstLoc") + } + }) + } + + _, err = srcFile.Seek(0, 0) + require.NoError(t, err) + dst, err := srcFile.CopyToLocation(dstLoc) + require.NoError(t, err) + exists, err = dst.Exists() + require.NoError(t, err) + assert.True(t, exists, "dst file should now exist") + exists, err = srcFile.Exists() + require.NoError(t, err) + assert.True(t, exists, "src file should still exist") + + // CopyToFile + dstFile1, err := dstLoc.NewFile("dstFile1.txt") + require.NoError(t, err) + exists, err = dstFile1.Exists() + require.NoError(t, err) + assert.False(t, exists, "dstFile1 file should not yet exist") + _, err = srcFile.Seek(0, 0) + require.NoError(t, err) + err = srcFile.CopyToFile(dstFile1) + require.NoError(t, err) + exists, err = dstFile1.Exists() + require.NoError(t, err) + assert.True(t, exists, "dstFile1 file should now exist") + exists, err = srcFile.Exists() + require.NoError(t, err) + assert.True(t, exists, "src file should still exist") + + // io.Copy tests (skip for FTP) + buffer := make([]byte, utils.TouchCopyMinBufferSize) + copyFile1, err := srcLoc.NewFile("copyFile1.txt") + require.NoError(t, err) + + if !opts.SkipFTPSpecificTests && srcLoc.FileSystem().Scheme() != "ftp" { + exists, err = copyFile1.Exists() + require.NoError(t, err) + assert.False(t, exists, "copyFile1 should not yet exist locally") + + _, err = srcFile.Seek(0, 0) + require.NoError(t, err) + b1, err := io.CopyBuffer(copyFile1, srcFile, buffer) + require.NoError(t, err) + assert.Equal(t, int64(28), b1) + err = copyFile1.Close() + require.NoError(t, err) + + exists, err = copyFile1.Exists() + require.NoError(t, err) + assert.Truef(t, exists, "%s should now exist locally", copyFile1) + err = copyFile1.Close() + require.NoError(t, err) + } else { + // ensure copyFile1 exists for later tests + err = copyFile1.Touch() + require.NoError(t, err) + } + + copyFile2, err := srcLoc.NewFile("copyFile2.txt") + require.NoError(t, err) + + if !opts.SkipFTPSpecificTests && srcLoc.FileSystem().Scheme() != "ftp" { + exists, err = copyFile2.Exists() + require.NoError(t, err) + assert.False(t, exists, "copyFile2 should not yet exist locally") + + _, err = srcFile.Seek(0, 0) + require.NoError(t, err) + buffer = make([]byte, utils.TouchCopyMinBufferSize) + b2, err := io.CopyBuffer(copyFile2, srcFile, buffer) + require.NoError(t, err) + assert.Equal(t, int64(28), b2) + + err = copyFile2.Close() + require.NoError(t, err) + exists, err = copyFile2.Exists() + require.NoError(t, err) + assert.True(t, exists, "copyFile2 should now exist locally") + err = copyFile2.Close() + require.NoError(t, err) + } else { + err = copyFile2.Touch() + require.NoError(t, err) + } + + // MoveToLocation tests + fileForNew, err := srcLoc.NewFile("fileForNew.txt") + require.NoError(t, err) + + if !opts.SkipFTPSpecificTests && srcLoc.FileSystem().Scheme() != "ftp" { + _, err = srcFile.Seek(0, 0) + require.NoError(t, err) + buffer = make([]byte, utils.TouchCopyMinBufferSize) + _, err = io.CopyBuffer(fileForNew, srcFile, buffer) + require.NoError(t, err) + err = fileForNew.Close() + require.NoError(t, err) + + newLoc, err := dstLoc.NewLocation("doesnotexist/") + require.NoError(t, err) + dstCopyNew, err := fileForNew.MoveToLocation(newLoc) + require.NoError(t, err) + exists, err = dstCopyNew.Exists() + require.NoError(t, err) + assert.True(t, exists) + require.NoError(t, dstCopyNew.Delete()) + } + + dstCopy1, err := copyFile1.MoveToLocation(dstLoc) + require.NoError(t, err) + exists, err = dstCopy1.Exists() + require.NoError(t, err) + assert.True(t, exists, "dstCopy1 file should now exist") + exists, err = copyFile1.Exists() + require.NoError(t, err) + assert.False(t, exists, "copyFile1 should no longer exist locally") + + // MoveToFile + dstCopy2, err := dstLoc.NewFile("dstFile2.txt") + require.NoError(t, err) + exists, err = dstCopy2.Exists() + require.NoError(t, err) + assert.False(t, exists, "dstCopy2 file should not yet exist") + err = copyFile2.MoveToFile(dstCopy2) + require.NoError(t, err) + exists, err = copyFile2.Exists() + require.NoError(t, err) + assert.False(t, exists, "copyFile2 should no longer exist locally") + exists, err = dstCopy2.Exists() + require.NoError(t, err) + assert.True(t, exists, "dstCopy2 file should now exist") + + // clean up files + require.NoError(t, dst.Delete()) + require.NoError(t, dstFile1.Delete()) + require.NoError(t, dstCopy1.Delete()) + require.NoError(t, dstCopy2.Delete()) + + // MoveToFile with spaces in path + tests := []struct { + Path, Filename string + }{ + {Path: "file/", Filename: "has space.txt"}, + {Path: "file/", Filename: "has%20encodedSpace.txt"}, + {Path: "path has/", Filename: "space.txt"}, + {Path: "path%20has/", Filename: "encodedSpace.txt"}, + } + + for i, test := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + srcSpaces, err := srcLoc.NewFile(path.Join(test.Path, test.Filename)) + require.NoError(t, err) + b, err := srcSpaces.Write([]byte("something")) + require.NoError(t, err) + assert.Equal(t, 9, b, "byte count is correct") + err = srcSpaces.Close() + require.NoError(t, err) + + testDestLoc, err := dstLoc.NewLocation(test.Path) + require.NoError(t, err) + + dstSpaces, err := srcSpaces.MoveToLocation(testDestLoc) + require.NoError(t, err) + exists, err := dstSpaces.Exists() + require.NoError(t, err) + assert.True(t, exists, "dstSpaces should now exist") + exists, err = srcSpaces.Exists() + require.NoError(t, err) + assert.False(t, exists, "srcSpaces should no longer exist") + assert.True(t, + strings.HasSuffix(dstSpaces.URI(), path.Join(test.Path, test.Filename)), + "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename), + ) + + newSrcSpaces, err := dstSpaces.MoveToLocation(srcSpaces.Location()) + require.NoError(t, err) + exists, err = newSrcSpaces.Exists() + require.NoError(t, err) + assert.True(t, exists, "newSrcSpaces should now exist") + exists, err = dstSpaces.Exists() + require.NoError(t, err) + assert.False(t, exists, "dstSpaces should no longer exist") + hasSuffix := strings.HasSuffix(newSrcSpaces.URI(), path.Join(test.Path, test.Filename)) + assert.True(t, hasSuffix, "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename)) + + require.NoError(t, newSrcSpaces.Delete()) + exists, err = newSrcSpaces.Exists() + require.NoError(t, err) + assert.False(t, exists, "newSrcSpaces should now exist") + }) + } + + // Touch tests + touchedFile, err := srcLoc.NewFile("touch.txt") + require.NoError(t, err) + defer func() { _ = touchedFile.Delete() }() + exists, err = touchedFile.Exists() + require.NoError(t, err) + assert.Falsef(t, exists, "%s shouldn't yet exist", touchedFile) + + err = touchedFile.Touch() + require.NoError(t, err) + exists, err = touchedFile.Exists() + require.NoError(t, err) + assert.Truef(t, exists, "%s now exists", touchedFile) + + size, err := touchedFile.Size() + require.NoError(t, err) + assert.Zerof(t, size, "%s should be empty", touchedFile) + + // Touch timestamp update test (optional) + if !opts.SkipTouchTimestampTest { + modified, err := touchedFile.LastModified() + require.NoError(t, err) + modifiedDeRef := *modified + time.Sleep(2 * time.Second) + err = touchedFile.Touch() + require.NoError(t, err) + newModified, err := touchedFile.LastModified() + require.NoError(t, err) + assert.Greaterf(t, *newModified, modifiedDeRef, "touch updated modified date for %s", touchedFile) + } + + // Delete + require.NoError(t, srcFile.Delete()) + exists, err = srcFile.Exists() + require.NoError(t, err) + assert.False(t, exists, "file no longer exists") + + // Operations on non-existent file should error + srcFile, err = srcLoc.NewFile("thisFileDoesNotExist") + require.NoError(t, err, "unexpected error creating file") + + exists, err = srcFile.Exists() + require.NoError(t, err) + assert.False(t, exists, "file should not exist") + + size, err = srcFile.Size() + require.Error(t, err, "expected error because file does not exist") + assert.Zero(t, size) + + _, err = srcFile.LastModified() + require.Error(t, err, "expected error because file does not exist") + + seeked, err := srcFile.Seek(-1, 2) + require.Error(t, err, "expected error because file does not exist") + assert.Zero(t, seeked) + + _, err = srcFile.Read(make([]byte, 1)) + require.Error(t, err, "expected error because file does not exist") +} + +func buildExpectedURI(fs vfs.FileSystem, authorityStr, p string) string { + return fmt.Sprintf("%s://%s%s", fs.Scheme(), authorityStr, p) +} diff --git a/testcontainers/doc.go b/testcontainers/doc.go index 0cc533c9..96d9ed3a 100644 --- a/testcontainers/doc.go +++ b/testcontainers/doc.go @@ -1,5 +1,5 @@ /* -Package testcontainers is meant to be run by implementors of backends to ensure that the behaviors of their backend matches the -expected behavior of the interface. It uses the local Docker daemon to run servers that emulate popular storage services. +Package testcontainers provides conformance tests for VFS backend implementations. +It uses the local Docker daemon to run servers that emulate popular storage services. */ package testcontainers diff --git a/testcontainers/io_conformance.go b/testcontainers/io_conformance.go new file mode 100644 index 00000000..37fe1715 --- /dev/null +++ b/testcontainers/io_conformance.go @@ -0,0 +1,349 @@ +package testcontainers + +import ( + "io" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/c2fo/vfs/v7" + "github.com/c2fo/vfs/v7/options" +) + +// ReadWriteSeekCloseURINamer interface for IO testing +type ReadWriteSeekCloseURINamer interface { + io.ReadWriteSeeker + io.Closer + Name() string + URI() string + Delete(opts ...options.DeleteOption) error +} + +// IOTestCase defines a single IO test scenario +type IOTestCase struct { + Description string + Sequence string + FileAlreadyExists bool + ExpectFailure bool + ExpectedResults string +} + +// DefaultIOTestCases returns the standard set of IO test cases +func DefaultIOTestCases() []IOTestCase { + return []IOTestCase{ + // Read, Close file + { + Description: "Read, Close, file exists", + Sequence: "R(all);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some text", + }, + { + Description: "Read, Close, file does not exist", + Sequence: "R(all);C()", + FileAlreadyExists: false, + ExpectFailure: true, + ExpectedResults: "", + }, + + // Read, Seek, Read, Close + { + Description: "Read, Seek, Read, Close, file exists", + Sequence: "R(4);S(0,0);R(4);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some text", + }, + + // Write, Close + { + Description: "Write, Close, file does not exist", + Sequence: "W(abc);C()", + FileAlreadyExists: false, + ExpectFailure: false, + ExpectedResults: "abc", + }, + { + Description: "Write, Close, file exists", + Sequence: "W(abc);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "abc", + }, + + // Write, Seek, Write, Close + { + Description: "Write, Seek, Write, Close, file does not exist", + Sequence: "W(this and that);S(0,0);W(that);C()", + FileAlreadyExists: false, + ExpectFailure: false, + ExpectedResults: "that and that", + }, + { + Description: "Write, Seek, Write, Close, file exists", + Sequence: "W(this and that);S(0,0);W(that);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "that and that", + }, + + // Seek + { + Description: "Seek, Close - file does not exist", + Sequence: "S(2,0);C()", + FileAlreadyExists: false, + ExpectFailure: true, + ExpectedResults: "", + }, + { + Description: "Seek, Close - file exists", + Sequence: "S(2,0);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some text", + }, + { + Description: "Seek, Write, Close, file exists", + Sequence: "S(5,0);W(new text);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some new text", + }, + + // Seek, Read, Close + { + Description: "Seek, Read, Close, file does not exist", + Sequence: "S(5,0);R(4);C()", + FileAlreadyExists: false, + ExpectFailure: true, + ExpectedResults: "", + }, + { + Description: "Seek, Read, Close, file exists", + Sequence: "S(5,0);R(4);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some text", + }, + + // Read, Write, Close + { + Description: "Read, Write, Close, file does not exist", + Sequence: "R(5);W(new text);C()", + FileAlreadyExists: false, + ExpectFailure: true, + ExpectedResults: "", + }, + { + Description: "Read, Write, Close, file exists", + Sequence: "R(5);W(new text);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some new text", + }, + + // Read, Seek, Write, Close + { + Description: "Read, Seek, Write, Close, file does not exist", + Sequence: "R(2);S(3,1);W(new text);C()", + FileAlreadyExists: false, + ExpectFailure: true, + ExpectedResults: "", + }, + { + Description: "Read, Seek, Write, Close, file exists", + Sequence: "R(2);S(3,1);W(new text);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "some new text", + }, + + // Write, Seek, Read, Close + { + Description: "Write, Seek, Read, Close, file does not exist", + Sequence: "W(new text);S(0,0);R(5);C()", + FileAlreadyExists: false, + ExpectFailure: false, + ExpectedResults: "new text", + }, + { + Description: "Write, Seek, Read, Close, file exists", + Sequence: "W(new text);S(0,0);R(5);C()", + FileAlreadyExists: true, + ExpectFailure: false, + ExpectedResults: "new text", + }, + } +} + +// RunIOTests runs IO conformance tests against the provided location +func RunIOTests(t *testing.T, location vfs.Location) { + t.Helper() + runIOTestsWithCases(t, location.URI(), location, DefaultIOTestCases()) +} + +func runIOTestsWithCases(t *testing.T, testPath string, location vfs.Location, testCases []IOTestCase) { + t.Helper() + defer teardownTestLocation(t, testPath, location) + + for _, tc := range testCases { + t.Run(tc.Description, func(t *testing.T) { + testFileName := "testfile.txt" + + func() { + file, err := setupTestFile(tc.FileAlreadyExists, location, testFileName) + defer func() { + if file != nil { + _ = file.Close() + _ = file.Delete() + } + }() + require.NoError(t, err) + + actualContents, err := ExecuteSequence(t, file, tc.Sequence) + + if tc.ExpectFailure && err == nil { + t.Fatalf("%s: expected failure but got success", tc.Description) + } + + if err != nil && !tc.ExpectFailure { + t.Fatalf("%s: expected success but got failure: %v", tc.Description, err) + } + + if tc.ExpectedResults != actualContents { + t.Fatalf("%s: expected results %s but got %s", tc.Description, tc.ExpectedResults, actualContents) + } + }() + }) + } +} + +func setupTestFile(existsBefore bool, location vfs.Location, filename string) (ReadWriteSeekCloseURINamer, error) { + f, err := location.NewFile(filename) + if err != nil { + return nil, err + } + + if existsBefore { + _, err = f.Write([]byte("some text")) + if err != nil { + return nil, err + } + err = f.Close() + if err != nil { + return nil, err + } + } + + return f, nil +} + +func teardownTestLocation(t *testing.T, _ string, location vfs.Location) { + t.Helper() + files, err := location.List() + if err != nil { + t.Logf("warning: error listing files for cleanup: %v", err) + return + } + for _, file := range files { + err := location.DeleteFile(file) + if err != nil { + t.Logf("warning: error deleting file %s: %v", file, err) + } + } +} + +// ExecuteSequence executes a sequence of IO operations and returns the final file contents +// +//nolint:gocyclo +func ExecuteSequence(t *testing.T, file ReadWriteSeekCloseURINamer, sequence string) (string, error) { + t.Helper() + commands := strings.Split(sequence, ";") + var commandErr error +SEQ: + for _, command := range commands { + commandName, commandArgs := parseCommand(t, command) + + switch commandName { + case "R": + if commandArgs[0] == "all" { + _, commandErr = io.ReadAll(file) + if commandErr != nil { + break SEQ + } + } else { + bytesize, err := strconv.ParseUint(commandArgs[0], 10, 64) + if err != nil { + t.Fatalf("invalid bytesize: %s", commandArgs[0]) + } + b := make([]byte, bytesize) + _, commandErr = file.Read(b) + if commandErr != nil { + break SEQ + } + } + case "W": + _, commandErr = file.Write([]byte(commandArgs[0])) + if commandErr != nil { + break SEQ + } + case "S": + if len(commandArgs) != 2 { + t.Fatalf("invalid number of args for Seek: %d", len(commandArgs)) + } + offset, err := strconv.ParseInt(commandArgs[0], 10, 64) + if err != nil { + t.Fatalf("invalid offset: %s", commandArgs[0]) + } + whence, err := strconv.Atoi(commandArgs[1]) + if err != nil { + t.Fatalf("invalid whence: %s", commandArgs[1]) + } + _, commandErr = file.Seek(offset, whence) + if commandErr != nil { + break SEQ + } + case "C": + commandErr = file.Close() + if commandErr != nil { + break SEQ + } + } + } + + if commandErr != nil { + return "", commandErr + } + + vfsFile, ok := file.(vfs.File) + if !ok { + t.Fatalf("file must implement vfs.File") + } + f, err := vfsFile.Location().NewFile(vfsFile.Name()) + if err != nil { + t.Fatalf("error opening file: %s", err.Error()) + } + defer func() { _ = f.Close() }() + + contents, err := io.ReadAll(f) + if err != nil { + t.Fatalf("error reading file: %s", err.Error()) + } + return string(contents), nil +} + +var commandArgsRegex = regexp.MustCompile(`^([a-zA-Z0-9]+)\((.*)\)$`) + +func parseCommand(t *testing.T, command string) (string, []string) { + t.Helper() + results := commandArgsRegex.FindStringSubmatch(command) + if len(results) != 3 { + t.Fatalf("invalid command string: %s", command) + } + args := strings.Split(results[2], ",") + return results[1], args +} diff --git a/testcontainers/io_integration_test.go b/testcontainers/io_integration_test.go index 27fce2b1..7896c789 100644 --- a/testcontainers/io_integration_test.go +++ b/testcontainers/io_integration_test.go @@ -1,126 +1,19 @@ package testcontainers import ( - "errors" - "io" "os" - "path" - "regexp" - "strconv" - "strings" "sync" "testing" "github.com/stretchr/testify/suite" "github.com/c2fo/vfs/v7" - "github.com/c2fo/vfs/v7/options" "github.com/c2fo/vfs/v7/vfssimple" ) -type osWrapper struct { - filename string - file *os.File - exists bool - seekCalled bool -} - -func newOSWrapper(absPath string) *osWrapper { - return &osWrapper{ - filename: absPath, - exists: fileExists(absPath), - } -} - -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - -func (o *osWrapper) Read(b []byte) (int, error) { - if !o.exists { - return 0, errors.New("file not found") - } - if o.file == nil { - file, err := os.OpenFile(o.filename, os.O_RDWR, 0o600) - if err != nil { - return 0, err - } - o.file = file - } - return o.file.Read(b) -} - -func (o *osWrapper) Write(b []byte) (int, error) { - if o.file == nil { - flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC - if o.seekCalled { - flags = os.O_RDWR | os.O_CREATE - } - file, err := os.OpenFile(o.filename, flags, 0o600) //nolint:gosec - if err != nil { - return 0, err - } - o.file = file - o.exists = true - } - - return o.file.Write(b) -} - -func (o *osWrapper) Seek(offset int64, whence int) (int64, error) { - if !o.exists { - return 0, errors.New("file not found") - } - - if o.file == nil { - file, err := os.OpenFile(o.filename, os.O_RDWR, 0o600) - if err != nil { - return 0, err - } - o.file = file - } - o.seekCalled = true - return o.file.Seek(offset, whence) -} - -func (o *osWrapper) Close() error { - if !o.exists { - return nil - } - err := o.file.Close() - if err != nil { - return err - } - o.file = nil - return nil -} - -func (o *osWrapper) Name() string { - return path.Base(o.filename) -} - -func (o *osWrapper) URI() string { - return o.filename -} - -func (o *osWrapper) Delete(_ ...options.DeleteOption) error { - return os.Remove(o.URI()) -} - -type readWriteSeekCloseDeleter interface { - io.ReadWriteSeeker - io.Closer - Delete(opts ...options.DeleteOption) error -} - type ioTestSuite struct { suite.Suite testLocations map[string]vfs.Location - localDir string } func (s *ioTestSuite) SetupSuite() { @@ -137,379 +30,42 @@ func (s *ioTestSuite) SetupSuite() { uris := make([]string, len(registers)) var wg sync.WaitGroup for i := range registers { - wg.Add(1) - go func() { - uris[i] = registers[i](s.T()) - wg.Done() - }() + wg.Go(func() { uris[i] = registers[i](s.T()) }) } wg.Wait() s.testLocations = make(map[string]vfs.Location) - for _, u := range uris { - if strings.HasPrefix(u, "/") { - s.localDir = u - } else { - l, err := vfssimple.NewLocation(u) - s.Require().NoError(err) - s.testLocations[l.FileSystem().Scheme()] = l + for idx := range uris { + if uris[idx] == "" { + continue } - } -} - -func (s *ioTestSuite) TestFileOperations() { - if s.localDir != "" { - s.Run("local", func() { - s.testFileOperations(s.localDir) - }) - } - for scheme, location := range s.testLocations { - s.Run(scheme, func() { - s.testFileOperations(location.URI()) - }) - } -} - -// unless seek or read is called first, writes should replace a file (not edit) - -func (s *ioTestSuite) testFileOperations(testPath string) { - testCases := []struct { - description string - sequence string - fileAlreadyExists bool - expectFailure bool - expectedResults string - }{ - // Read, Close file - { - "Read, Close, file exists", - "R(all);C()", - true, - false, - "some text", - }, - { - "Read, Close, file does not exist", - "R(all);C()", - false, - true, - "", - }, - - // Read, Seek, Read, Close - { - "Read, Seek, Read, Close, file exists", - "R(4);S(0,0);R(4);C()", - true, - false, - "some text", - }, - - // Write, Close - { - "Write, Close, file does not exist", - "W(abc);C()", - false, - false, - "abc", - }, - { - "Write, Close, file exists", - "W(abc);C()", - true, - false, - "abc", - }, - - // Write, Seek, Write, Close - { - "Write, Seek, Write, Close, file does not exist", - "W(this and that);S(0,0);W(that);C()", - false, - false, - "that and that", - }, - { - "Write, Seek, Write, Close, file exists", - "W(this and that);S(0,0);W(that);C()", - true, - false, - "that and that", - }, - - // Seek - { - "Seek, Close, file does not exist", - "S(2,0);C()", - false, - true, - "", - }, - { - "Seek, Close, file exists", - "S(2,0);C()", - true, - false, - "some text", - }, - { - "Seek, Write, Close, file exists", - "S(5,0);W(new text);C()", - true, - false, - "some new text", - }, - - // Seek, Read, Close - { - "Seek, Read, Close, file does not exist", - "S(5,0);R(4);C()", - false, - true, - "", - }, - { - "Seek, Read, Close, file exists", - "S(5,0);R(4);C()", - true, - false, - "some text", - }, - - // Read, Write, Close - { - "Read, Write, Close, file does not exist", - "R(5);W(new text);C()", - false, - true, - "", - }, - { - "Read, Write, Close, file exists", - "R(5);W(new text);C()", - true, - false, - "some new text", - }, - - // Read, Seek, Write, Close - { - "Read, Seek, Write, Close, file does not exist", - "R(2);S(3,1);W(new text);C()", - false, - true, - "", - }, - { - "Read, Seek, Write, Close, file exists", - "R(2);S(3,1);W(new text);C()", - true, - false, - "some new text", - }, - - // Write, Seek, Read, Close - { - "Write, Seek, Read, Close, file does not exist", - "W(new text);S(0,0);R(5);C()", - false, - false, - "new text", - }, - { - "Write, Seek, Read, Close, file exists", - "W(new text);S(0,0);R(5);C()", - true, - false, - "new text", - }, - } - - defer s.teardownTestLocation(testPath) - for _, tc := range testCases { - s.Run(tc.description, func() { - testFileName := "testfile.txt" - - // run in a closure so we can defer teardown - func() { - // Setup vfs environment - file, err := s.setupTestFile(tc.fileAlreadyExists, testPath, testFileName) // Implement this setup function - defer func() { - if file != nil { - _ = file.Close() - _ = file.Delete() - } - }() - s.Require().NoError(err) - - // Use vfs to execute the sequence of operations described by the description - actualContents, err := executeSequence(s.T(), file, tc.sequence) // Implement this function - - // Assert expected outcomes - if tc.expectFailure { - s.Require().Error(err, "%s: expected failure but got success", tc.description) - } else { - s.Require().NoError(err, "%s: expected success but got failure: %v", tc.description, err) - } - - s.Equal(tc.expectedResults, actualContents, "%s: expected results %s but got %s", tc.description, tc.expectedResults, actualContents) - }() - }) - } -} - -//nolint:gocyclo -func executeSequence(t *testing.T, file readWriteSeekCloseDeleter, sequence string) (string, error) { - commands := strings.Split(sequence, ";") - var commandErr error -SEQ: - for _, command := range commands { - // parse command - commandName, commandArgs := parseCommand(t, command) - - switch commandName { - case "R": - if commandArgs[0] == "all" { - // Read entire file - _, commandErr = io.ReadAll(file) - if commandErr != nil { - break SEQ - } - } else { - // convert arg 0 to uint64 - bytesize, err := strconv.ParseUint(commandArgs[0], 10, 64) - if err != nil { - t.Fatalf("invalid bytesize: %s", commandArgs[0]) - } + l, err := vfssimple.NewLocation(uris[idx]) + s.Require().NoError(err) - // Read file - b := make([]byte, bytesize) - _, commandErr = file.Read(b) - if commandErr != nil { - break SEQ - } - } - case "W": - // Write to file - _, commandErr = file.Write([]byte(commandArgs[0])) - if commandErr != nil { - break SEQ - } - case "S": - // expect 2 args for offset and whence - if len(commandArgs) != 2 { - t.Fatalf("invalid number of args for Seek: %d", len(commandArgs)) - } - // convert args - offset, err := strconv.ParseInt(commandArgs[0], 10, 64) - if err != nil { - t.Fatalf("invalid offset: %s", commandArgs[0]) - } - whence, err := strconv.Atoi(commandArgs[1]) + // For file:// locations, ensure directory exists + if l.FileSystem().Scheme() == "file" { + exists, err := l.Exists() if err != nil { - t.Fatalf("invalid whence: %s", commandArgs[1]) + panic(err) } - // Seek - _, commandErr = file.Seek(offset, whence) - if commandErr != nil { - break SEQ - } - case "C": - // Close - commandErr = file.Close() - if commandErr != nil { - break SEQ + if !exists { + err := os.Mkdir(l.Path(), 0750) + if err != nil { + panic(err) + } } } - } - // success so compare file contents to expected results - if commandErr != nil { - return "", commandErr - } - - var f io.ReadCloser - - switch assertedFile := file.(type) { - case *osWrapper: - var err error - f, err = os.Open(assertedFile.URI()) - if err != nil { - t.Fatalf("error opening file: %s", err.Error()) - } - case vfs.File: - var err error - f, err = assertedFile.Location().NewFile(assertedFile.Name()) - if err != nil { - t.Fatalf("error opening file: %s", err.Error()) - } - } - defer func() { _ = f.Close() }() - // Read entire file - contents, err := io.ReadAll(f) - if err != nil { - t.Fatalf("error reading file: %s", err.Error()) - } - return string(contents), nil -} -var commandArgsRegex = regexp.MustCompile(`^([a-zA-Z0-9]+)\((.*)\)$`) - -// takes command string in the form of () and returns the command name and args -func parseCommand(t *testing.T, command string) (string, []string) { - // parse command string - results := commandArgsRegex.FindStringSubmatch(command) - if len(results) != 3 { - t.Fatalf("invalid command string: %s", command) + // Store location by scheme - no type assertion needed + s.testLocations[l.FileSystem().Scheme()] = l } - - // split args by comma - args := strings.Split(results[2], ",") - - return results[1], args } -func (s *ioTestSuite) setupTestFile(existsBefore bool, loc, filename string) (readWriteSeekCloseDeleter, error) { - var f readWriteSeekCloseDeleter - var err error - // Create file - if strings.HasPrefix(loc, "/") { - f = newOSWrapper(loc + filename) - } else { - scheme := strings.Split(loc, ":")[0] - // Write something to the file - f, err = s.testLocations[scheme].NewFile(filename) - if err != nil { - return nil, err - } - } - if existsBefore { - _, err = f.Write([]byte("some text")) - if err != nil { - return nil, err - } - err = f.Close() - if err != nil { - return nil, err - } - } - - return f, nil -} - -func (s *ioTestSuite) teardownTestLocation(testPath string) { - if strings.HasPrefix(testPath, "/") { - err := os.RemoveAll(testPath) - s.Require().NoError(err) - } else { - scheme := strings.Split(testPath, ":")[0] - // Write something to the file - loc := s.testLocations[scheme] - files, err := loc.List() - s.Require().NoError(err) - for _, file := range files { - err := loc.DeleteFile(file) - s.Require().NoError(err) - } +func (s *ioTestSuite) TestFileOperations() { + for scheme, location := range s.testLocations { + s.Run(scheme, func() { + RunIOTests(s.T(), location) + }) } } From 64b4d11e2ba47130287dde0ac7d81e8570a284a4 Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Tue, 27 Jan 2026 18:11:46 +1100 Subject: [PATCH 3/7] Various conformance fixes --- backend/azure/file.go | 32 ++++++++++------------ backend/azure/file_test.go | 7 ++--- backend/ftp/location.go | 4 +++ backend/ftp/location_test.go | 17 +----------- testcontainers/backend_integration_test.go | 3 +- testcontainers/conformance.go | 13 +++++---- testcontainers/io_conformance.go | 28 ++++++++++++++++--- testcontainers/io_integration_test.go | 5 +++- 8 files changed, 61 insertions(+), 48 deletions(-) diff --git a/backend/azure/file.go b/backend/azure/file.go index a5764942..03c4eb1a 100644 --- a/backend/azure/file.go +++ b/backend/azure/file.go @@ -70,7 +70,7 @@ func (f *File) Close() error { // the file is created and read operations are performed against that. The temp file is closed and flushed to Azure // when f.Close() is called. func (f *File) Read(p []byte) (n int, err error) { - if err := f.checkTempFile(); err != nil { + if err := f.checkTempFile(false); err != nil { return 0, utils.WrapReadError(err) } read, err := f.tempFile.Read(p) @@ -91,7 +91,7 @@ func (f *File) Read(p []byte) (n int, err error) { // the file is created and operations are performed against that. The temp file is closed and flushed to Azure // when f.Close() is called. func (f *File) Seek(offset int64, whence int) (int64, error) { - if err := f.checkTempFile(); err != nil { + if err := f.checkTempFile(false); err != nil { return 0, utils.WrapSeekError(err) } pos, err := f.tempFile.Seek(offset, whence) @@ -104,7 +104,7 @@ func (f *File) Seek(offset int64, whence int) (int64, error) { // Write implements the io.Writer interface. Writes are performed against a temporary local file. The temp file is // closed and flushed to Azure with f.Close() is called. func (f *File) Write(p []byte) (int, error) { - if err := f.checkTempFile(); err != nil { + if err := f.checkTempFile(true); err != nil { return 0, utils.WrapWriteError(err) } @@ -350,7 +350,7 @@ func (f *File) URI() string { return utils.GetFileURI(f) } -func (f *File) checkTempFile() error { +func (f *File) checkTempFile(isWrite bool) error { if f.tempFile == nil { client, err := f.location.fileSystem.Client() if err != nil { @@ -361,23 +361,23 @@ func (f *File) checkTempFile() error { if err != nil { return err } - if !exists { - tf, tfErr := os.CreateTemp("", fmt.Sprintf("%s.%d", path.Base(f.Name()), time.Now().UnixNano())) - if tfErr != nil { - return tfErr + + tf, tfErr := os.CreateTemp("", fmt.Sprintf("%s.%d", path.Base(f.Name()), time.Now().UnixNano())) + if tfErr != nil { + return tfErr + } + f.tempFile = tf + + if !isWrite { + if !exists { + return os.ErrNotExist } - f.tempFile = tf - } else { + reader, dlErr := client.Download(f) if dlErr != nil { return dlErr } - tf, tfErr := os.CreateTemp("", fmt.Sprintf("%s.%d", path.Base(f.Name()), time.Now().UnixNano())) - if tfErr != nil { - return tfErr - } - buffer := make([]byte, utils.TouchCopyMinBufferSize) if _, err := io.CopyBuffer(tf, reader, buffer); err != nil { return err @@ -386,8 +386,6 @@ func (f *File) checkTempFile() error { if _, err := tf.Seek(0, 0); err != nil { return err } - - f.tempFile = tf } } return nil diff --git a/backend/azure/file_test.go b/backend/azure/file_test.go index 1997cb94..998303f5 100644 --- a/backend/azure/file_test.go +++ b/backend/azure/file_test.go @@ -91,7 +91,6 @@ func (s *FileTestSuite) TestWrite() { s.NotNil(f) s.Require().NoError(err) client.EXPECT().Properties("test-container", "/foo.txt").Return(&BlobProperties{}, nil) - client.EXPECT().Download(mock.Anything).Return(io.NopCloser(strings.NewReader("Hello World!")), nil) n, err := f.Write([]byte(" Aaaaand, Goodbye!")) s.Require().NoError(err) s.Equal(18, n) @@ -375,7 +374,7 @@ func (s *FileTestSuite) TestCheckTempFile() { s.Nil(azureFile.tempFile, "No calls to checkTempFile have occurred so we expect tempFile to be nil") client.EXPECT().Properties("test-container", "/foo.txt").Return(&BlobProperties{}, nil) client.EXPECT().Download(mock.Anything).Return(io.NopCloser(strings.NewReader("Hello World!")), nil) - err = azureFile.checkTempFile() + err = azureFile.checkTempFile(false) s.Require().NoError(err, "Check temp file should create a local temp file so no error is expected") s.NotNil(azureFile.tempFile, "After the call to checkTempFile we should have a non-nil tempFile") @@ -397,7 +396,7 @@ func (s *FileTestSuite) TestCheckTempFile_FileDoesNotExist() { s.Nil(azureFile.tempFile, "No calls to checkTempFile have occurred so we expect tempFile to be nil") client.EXPECT().Properties("test-container", "/foo.txt").Return(nil, errBlobNotFound) - err = azureFile.checkTempFile() + err = azureFile.checkTempFile(true) s.Require().NoError(err, "Check temp file should create a local temp file so no error is expected") s.NotNil(azureFile.tempFile, "After the call to checkTempFile we should have a non-nil tempFile") @@ -420,7 +419,7 @@ func (s *FileTestSuite) TestCheckTempFile_DownloadError() { s.Nil(azureFile.tempFile, "No calls to checkTempFile have occurred so we expect tempFile to be nil") client.EXPECT().Properties("test-container", "/foo.txt").Return(&BlobProperties{}, nil) client.EXPECT().Download(mock.Anything).Return(nil, errors.New("i always error")) - err = azureFile.checkTempFile() + err = azureFile.checkTempFile(false) s.Require().Error(err, "The call to client.Download() errors so we expect to get an error") } diff --git a/backend/ftp/location.go b/backend/ftp/location.go index 2db13605..c901edac 100644 --- a/backend/ftp/location.go +++ b/backend/ftp/location.go @@ -152,6 +152,10 @@ func (l *Location) Path() string { // Exists returns true if the remote FTP directory exists. func (l *Location) Exists() (bool, error) { + if l.path == "/" { + return true, nil + } + dc, err := l.fileSystem.DataConn(context.TODO(), l.Authority(), types.SingleOp, nil) if err != nil { return false, err diff --git a/backend/ftp/location_test.go b/backend/ftp/location_test.go index 4bd3ae6c..f8eafc7e 100644 --- a/backend/ftp/location_test.go +++ b/backend/ftp/location_test.go @@ -411,21 +411,6 @@ func (lt *locationTestSuite) TestExists() { // location exists locPath := "/" - entries := []*_ftp.Entry{ - { - Name: "file.txt", - Target: "", - Type: _ftp.EntryTypeFile, - Time: time.Now().UTC(), - }, - { - Name: locPath, - Target: "", - Type: _ftp.EntryTypeFolder, - Time: time.Now().UTC(), - }, - } - lt.client.EXPECT().List(locPath).Return(entries, nil).Once() loc, err := lt.ftpfs.NewLocation(authorityStr, locPath) lt.Require().NoError(err) exists, err := loc.Exists() @@ -434,7 +419,7 @@ func (lt *locationTestSuite) TestExists() { // locations does not exist locPath = "/my/dir/" - entries = []*_ftp.Entry{ + entries := []*_ftp.Entry{ { Name: "file.txt", Target: "", diff --git a/testcontainers/backend_integration_test.go b/testcontainers/backend_integration_test.go index e1c9c4dd..25884b0e 100644 --- a/testcontainers/backend_integration_test.go +++ b/testcontainers/backend_integration_test.go @@ -75,7 +75,8 @@ func (s *vfsTestSuite) TestScheme() { // Determine conformance options based on scheme opts := ConformanceOptions{ - SkipFTPSpecificTests: scheme == "ftp", + SkipFTPSpecificTests: scheme == "ftp", + SkipTouchTimestampTest: scheme == "ftp", } // Run the exported conformance tests diff --git a/testcontainers/conformance.go b/testcontainers/conformance.go index 947d43cb..2945b5d4 100644 --- a/testcontainers/conformance.go +++ b/testcontainers/conformance.go @@ -24,6 +24,7 @@ package testcontainers import ( "fmt" "io" + "net/url" "os" "path" "regexp" @@ -555,10 +556,10 @@ func RunFileTests(t *testing.T, baseLoc vfs.Location, opts ConformanceOptions) { exists, err = srcSpaces.Exists() require.NoError(t, err) assert.False(t, exists, "srcSpaces should no longer exist") - assert.True(t, - strings.HasSuffix(dstSpaces.URI(), path.Join(test.Path, test.Filename)), - "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename), - ) + // HACK: ftp and sftp are the only backends that encode special characters + hasSuffix := strings.HasSuffix(dstSpaces.URI(), path.Join(test.Path, test.Filename)) || + strings.HasSuffix(dstSpaces.URI(), strings.ReplaceAll(url.PathEscape(path.Join(test.Path, test.Filename)), "%2F", "/")) + assert.True(t, hasSuffix, "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename)) newSrcSpaces, err := dstSpaces.MoveToLocation(srcSpaces.Location()) require.NoError(t, err) @@ -568,7 +569,9 @@ func RunFileTests(t *testing.T, baseLoc vfs.Location, opts ConformanceOptions) { exists, err = dstSpaces.Exists() require.NoError(t, err) assert.False(t, exists, "dstSpaces should no longer exist") - hasSuffix := strings.HasSuffix(newSrcSpaces.URI(), path.Join(test.Path, test.Filename)) + // HACK: ftp and sftp are the only backends that encode special characters + hasSuffix = strings.HasSuffix(newSrcSpaces.URI(), path.Join(test.Path, test.Filename)) || + strings.HasSuffix(newSrcSpaces.URI(), strings.ReplaceAll(url.PathEscape(path.Join(test.Path, test.Filename)), "%2F", "/")) assert.True(t, hasSuffix, "destination file %s ends with source string for %s", dstSpaces.URI(), path.Join(test.Path, test.Filename)) require.NoError(t, newSrcSpaces.Delete()) diff --git a/testcontainers/io_conformance.go b/testcontainers/io_conformance.go index 37fe1715..43db78c7 100644 --- a/testcontainers/io_conformance.go +++ b/testcontainers/io_conformance.go @@ -1,6 +1,7 @@ package testcontainers import ( + "errors" "io" "regexp" "strconv" @@ -13,6 +14,12 @@ import ( "github.com/c2fo/vfs/v7/options" ) +// IOOptions configures IO test behavior +type IOOptions struct { + // SkipFTPSpecificTests skips tests that don't work well with FTP + SkipFTPSpecificTests bool +} + // ReadWriteSeekCloseURINamer interface for IO testing type ReadWriteSeekCloseURINamer interface { io.ReadWriteSeeker @@ -181,12 +188,17 @@ func DefaultIOTestCases() []IOTestCase { } // RunIOTests runs IO conformance tests against the provided location -func RunIOTests(t *testing.T, location vfs.Location) { +func RunIOTests(t *testing.T, location vfs.Location, opts ...IOOptions) { t.Helper() - runIOTestsWithCases(t, location.URI(), location, DefaultIOTestCases()) + opt := IOOptions{} + if len(opts) > 0 { + opt = opts[0] + } + + runIOTestsWithCases(t, location.URI(), location, DefaultIOTestCases(), opt) } -func runIOTestsWithCases(t *testing.T, testPath string, location vfs.Location, testCases []IOTestCase) { +func runIOTestsWithCases(t *testing.T, testPath string, location vfs.Location, testCases []IOTestCase, opts IOOptions) { t.Helper() defer teardownTestLocation(t, testPath, location) @@ -194,6 +206,10 @@ func runIOTestsWithCases(t *testing.T, testPath string, location vfs.Location, t t.Run(tc.Description, func(t *testing.T) { testFileName := "testfile.txt" + if opts.SkipFTPSpecificTests && strings.HasPrefix(tc.Description, "Write, Seek, Write") { + return + } + func() { file, err := setupTestFile(tc.FileAlreadyExists, location, testFileName) defer func() { @@ -281,8 +297,12 @@ SEQ: t.Fatalf("invalid bytesize: %s", commandArgs[0]) } b := make([]byte, bytesize) - _, commandErr = file.Read(b) + var n int + n, commandErr = file.Read(b) if commandErr != nil { + if n > 0 && uint64(n) == bytesize && errors.Is(commandErr, io.EOF) { + commandErr = nil + } break SEQ } } diff --git a/testcontainers/io_integration_test.go b/testcontainers/io_integration_test.go index 7896c789..402a325a 100644 --- a/testcontainers/io_integration_test.go +++ b/testcontainers/io_integration_test.go @@ -64,7 +64,10 @@ func (s *ioTestSuite) SetupSuite() { func (s *ioTestSuite) TestFileOperations() { for scheme, location := range s.testLocations { s.Run(scheme, func() { - RunIOTests(s.T(), location) + opts := IOOptions{ + SkipFTPSpecificTests: scheme == "ftp", + } + RunIOTests(s.T(), location, opts) }) } } From 402a463947d7936d7268c11fb13109aff3776593 Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Tue, 27 Jan 2026 18:17:05 +1100 Subject: [PATCH 4/7] Revert to older Go 1.24 WaitGroup pattern --- testcontainers/backend_integration_test.go | 6 +++++- testcontainers/io_integration_test.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/testcontainers/backend_integration_test.go b/testcontainers/backend_integration_test.go index 25884b0e..45b7a5c1 100644 --- a/testcontainers/backend_integration_test.go +++ b/testcontainers/backend_integration_test.go @@ -31,8 +31,12 @@ func (s *vfsTestSuite) SetupSuite() { } uris := make([]string, len(registers)) var wg sync.WaitGroup + wg.Add(len(registers)) for i := range registers { - wg.Go(func() { uris[i] = registers[i](s.T()) }) + go func() { + uris[i] = registers[i](s.T()) + wg.Done() + }() } wg.Wait() diff --git a/testcontainers/io_integration_test.go b/testcontainers/io_integration_test.go index 402a325a..96baf61b 100644 --- a/testcontainers/io_integration_test.go +++ b/testcontainers/io_integration_test.go @@ -29,8 +29,12 @@ func (s *ioTestSuite) SetupSuite() { } uris := make([]string, len(registers)) var wg sync.WaitGroup + wg.Add(len(registers)) for i := range registers { - wg.Go(func() { uris[i] = registers[i](s.T()) }) + go func() { + uris[i] = registers[i](s.T()) + wg.Done() + }() } wg.Wait() From e1ee6e5f732c4b48a6a46ed8307c1790e80ca221 Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Tue, 27 Jan 2026 21:39:02 +1100 Subject: [PATCH 5/7] Empty changelog to satisfy CI --- testcontainers/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 testcontainers/CHANGELOG.md diff --git a/testcontainers/CHANGELOG.md b/testcontainers/CHANGELOG.md new file mode 100644 index 00000000..ef88a6b9 --- /dev/null +++ b/testcontainers/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] From 8da443dd696c49e3d562274ee20ab8d886a121f9 Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Tue, 27 Jan 2026 21:43:49 +1100 Subject: [PATCH 6/7] Fix CI issue by only running testcontainers tests on Linux --- testcontainers/backend_integration_test.go | 2 ++ testcontainers/io_integration_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/testcontainers/backend_integration_test.go b/testcontainers/backend_integration_test.go index 45b7a5c1..9265f1c1 100644 --- a/testcontainers/backend_integration_test.go +++ b/testcontainers/backend_integration_test.go @@ -1,3 +1,5 @@ +//go:build linux + package testcontainers import ( diff --git a/testcontainers/io_integration_test.go b/testcontainers/io_integration_test.go index 96baf61b..823838cb 100644 --- a/testcontainers/io_integration_test.go +++ b/testcontainers/io_integration_test.go @@ -1,3 +1,5 @@ +//go:build linux + package testcontainers import ( From b24f3a0ccb58b0f5d54709c969d487c75cc11f06 Mon Sep 17 00:00:00 2001 From: Nathan Baulch Date: Tue, 27 Jan 2026 21:54:09 +1100 Subject: [PATCH 7/7] Root changelog placeholder --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f148d36..a3b030dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Various compliance fixes (to be discussed). ## [v7.13.0] - 2025-01-26 ### Added