diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b63579..5756f28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,7 @@ jobs: - name: build and test run: | go get -v - # Increase timeout to 180s for container tests - go test -timeout=180s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./... + go test -timeout=300s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./... cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov go build -race env: diff --git a/README.md b/README.md index 56053f3..bc5d1e7 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,12 @@ These capture utilities are useful for testing functions that write directly to The `containers` package provides several test containers for integration testing: -- `SSHTestContainer`: SSH server container for testing SSH connections and operations -- `FTPTestContainer`: FTP server container for testing FTP file transfers and operations +- `SSHTestContainer`: SSH server container for testing SSH connections with file operations (upload, download, list, delete) +- `FTPTestContainer`: FTP server container for testing FTP file operations (upload, download, list, delete) - `PostgresTestContainer`: PostgreSQL database container with automatic database creation - `MySQLTestContainer`: MySQL database container with automatic database creation - `MongoTestContainer`: MongoDB container with support for multiple versions (5, 6, 7) -- `LocalstackTestContainer`: LocalStack container with S3 service for AWS testing +- `LocalstackTestContainer`: LocalStack container with S3 service for AWS testing, including file operations (upload, download, list, delete) ## Install and update @@ -179,6 +179,28 @@ func TestWithSSH(t *testing.T) { // use ssh.Address() to get host:port // default user is "test" sshAddr := ssh.Address() + + // Upload a file to the SSH server + localFile := "/path/to/local/file.txt" + remotePath := "/config/file.txt" + err := ssh.SaveFile(ctx, localFile, remotePath) + require.NoError(t, err) + + // Download a file from the SSH server + downloadPath := "/path/to/download/location.txt" + err = ssh.GetFile(ctx, remotePath, downloadPath) + require.NoError(t, err) + + // List files on the SSH server + files, err := ssh.ListFiles(ctx, "/config") + require.NoError(t, err) + for _, file := range files { + fmt.Println(file.Name(), file.Mode(), file.Size()) + } + + // Delete a file from the SSH server + err = ssh.DeleteFile(ctx, remotePath) + require.NoError(t, err) } // Localstack (S3) test container @@ -189,13 +211,37 @@ func TestWithS3(t *testing.T) { s3Client, bucketName := ls.MakeS3Connection(ctx, t) - // put object example + // put object example (using direct S3 API) _, err := s3Client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucketName), Key: aws.String("test-key"), Body: strings.NewReader("test content"), }) require.NoError(t, err) + + // File operations using higher-level container methods + + // Upload a file to S3 + localFile := "/path/to/local/file.txt" + objectKey := "documents/file.txt" + err = ls.SaveFile(ctx, localFile, bucketName, objectKey) + require.NoError(t, err) + + // Download a file from S3 + downloadPath := "/path/to/download/location.txt" + err = ls.GetFile(ctx, bucketName, objectKey, downloadPath) + require.NoError(t, err) + + // List objects in bucket (optionally with prefix) + objects, err := ls.ListFiles(ctx, bucketName, "documents/") + require.NoError(t, err) + for _, obj := range objects { + fmt.Println(*obj.Key, *obj.Size) + } + + // Delete an object from S3 + err = ls.DeleteFile(ctx, bucketName, objectKey) + require.NoError(t, err) } // FTP test container @@ -227,5 +273,9 @@ func TestWithFTP(t *testing.T) { for _, entry := range entries { fmt.Println(entry.Name, entry.Type) // Type: 0 for file, 1 for directory } + + // Delete a file + err = ftpContainer.DeleteFile(ctx, remotePath) + require.NoError(t, err) } ``` \ No newline at end of file diff --git a/containers/ftp.go b/containers/ftp.go index 619c8a1..9e0d919 100644 --- a/containers/ftp.go +++ b/containers/ftp.go @@ -38,7 +38,7 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer { fixedHostControlPort = "2121" ) - // Set up logging for testcontainers if the appropriate API is available + // set up logging for testcontainers if the appropriate API is available t.Logf("Setting up FTP test container") pasvPortRangeContainer := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort) @@ -335,6 +335,25 @@ func (fc *FTPTestContainer) ListFiles(ctx context.Context, remotePath string) ([ return entries, nil } +// DeleteFile deletes a file from the FTP server +func (fc *FTPTestContainer) DeleteFile(ctx context.Context, remotePath string) error { + c, err := fc.connect(ctx) + if err != nil { + return fmt.Errorf("failed to connect to FTP server for DeleteFile: %w", err) + } + defer func() { + if quitErr := c.Quit(); quitErr != nil { + fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr) + } + }() + + cleanRemotePath := filepath.ToSlash(remotePath) + if err := c.Delete(cleanRemotePath); err != nil { + return fmt.Errorf("failed to delete remote file %s: %w", cleanRemotePath, err) + } + return nil +} + // Close terminates the container func (fc *FTPTestContainer) Close(ctx context.Context) error { if fc.Container != nil { diff --git a/containers/ftp_test.go b/containers/ftp_test.go index bf94347..689deef 100644 --- a/containers/ftp_test.go +++ b/containers/ftp_test.go @@ -328,3 +328,62 @@ func TestSplitPath(t *testing.T) { }) } } + +// TestFTPDeleteFile tests the DeleteFile method for the FTP container +func TestFTPDeleteFile(t *testing.T) { + if testing.Short() { + t.Skip("skipping FTP delete file test in short mode") + } + if os.Getenv("CI") != "" && os.Getenv("RUN_FTP_TESTS_ON_CI") == "" { + t.Skip("skipping FTP delete file test in CI environment unless RUN_FTP_TESTS_ON_CI is set") + } + + ctx := context.Background() + ftpContainer := NewFTPTestContainer(ctx, t) + defer func() { assert.NoError(t, ftpContainer.Close(context.Background())) }() + + // create a temporary directory and test file + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "delete-test.txt") + testContent := "This file will be deleted" + require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0o600)) + + // upload the file + remotePath := "/ftp/ftpuser/delete-test.txt" + err := ftpContainer.SaveFile(ctx, testFile, remotePath) + require.NoError(t, err, "Failed to upload file for deletion test") + + // verify file exists on FTP server + entries, err := ftpContainer.ListFiles(ctx, "/ftp/ftpuser") + require.NoError(t, err) + + found := false + for _, entry := range entries { + if entry.Name == "delete-test.txt" { + found = true + break + } + } + require.True(t, found, "File should exist before deletion") + + // delete the file + err = ftpContainer.DeleteFile(ctx, remotePath) + require.NoError(t, err, "Failed to delete file") + + // verify file no longer exists + entries, err = ftpContainer.ListFiles(ctx, "/ftp/ftpuser") + require.NoError(t, err) + + found = false + for _, entry := range entries { + if entry.Name == "delete-test.txt" { + found = true + break + } + } + require.False(t, found, "File should have been deleted") + + // test deleting a non-existent file + err = ftpContainer.DeleteFile(ctx, "non-existent-file.txt") + require.Error(t, err, "Deleting a non-existent file should return an error") +} diff --git a/containers/localstack.go b/containers/localstack.go index 4432673..d2353c2 100644 --- a/containers/localstack.go +++ b/containers/localstack.go @@ -1,8 +1,13 @@ package containers import ( + "bytes" "context" "fmt" + "io" + "os" + "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -11,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -79,6 +85,139 @@ func (lc *LocalstackTestContainer) MakeS3Connection(ctx context.Context, t *test return client, bucketName } +// createS3Client creates an S3 client connected to the test container +func (lc *LocalstackTestContainer) createS3Client(ctx context.Context) (*s3.Client, error) { + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(lc.Endpoint) + o.UsePathStyle = true + }) + + return client, nil +} + +// GetFile downloads a file from S3 +func (lc *LocalstackTestContainer) GetFile(ctx context.Context, bucketName, objectKey, localPath string) error { + client, err := lc.createS3Client(ctx) + if err != nil { + return fmt.Errorf("failed to create S3 client: %w", err) + } + + localDir := filepath.Dir(localPath) + if err := os.MkdirAll(localDir, 0o750); err != nil { + return fmt.Errorf("failed to create local directory %s: %w", localDir, err) + } + + if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(localDir)) { + return fmt.Errorf("localPath %s attempts to escape from directory %s", localPath, localDir) + } + + // get object from S3 + output, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + if err != nil { + return fmt.Errorf("failed to get object %s from bucket %s: %w", objectKey, bucketName, err) + } + defer output.Body.Close() + + // create local file + file, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("failed to create local file %s: %w", localPath, err) + } + defer file.Close() + + // copy object content to local file + if _, err := io.Copy(file, output.Body); err != nil { + return fmt.Errorf("failed to copy content from S3 object to local file: %w", err) + } + + return nil +} + +// SaveFile uploads a file to S3 +func (lc *LocalstackTestContainer) SaveFile(ctx context.Context, localPath, bucketName, objectKey string) error { + client, err := lc.createS3Client(ctx) + if err != nil { + return fmt.Errorf("failed to create S3 client: %w", err) + } + + if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(filepath.Dir(localPath))) { + return fmt.Errorf("localPath %s attempts to escape from its directory", localPath) + } + + // read local file + fileData, err := os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("failed to read local file %s: %w", localPath, err) + } + + // upload to S3 + _, err = client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: bytes.NewReader(fileData), + }) + if err != nil { + return fmt.Errorf("failed to upload file to S3: %w", err) + } + + return nil +} + +// ListFiles lists objects in an S3 bucket, optionally with a prefix +func (lc *LocalstackTestContainer) ListFiles(ctx context.Context, bucketName, prefix string) ([]types.Object, error) { + client, err := lc.createS3Client(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create S3 client: %w", err) + } + + // list objects + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + } + + // add prefix if provided + if prefix != "" { + input.Prefix = aws.String(prefix) + } + + output, err := client.ListObjectsV2(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err) + } + + return output.Contents, nil +} + +// DeleteFile deletes an object from S3 +func (lc *LocalstackTestContainer) DeleteFile(ctx context.Context, bucketName, objectKey string) error { + client, err := lc.createS3Client(ctx) + if err != nil { + return fmt.Errorf("failed to create S3 client: %w", err) + } + + // delete object + _, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + if err != nil { + return fmt.Errorf("failed to delete object %s from bucket %s: %w", objectKey, bucketName, err) + } + + return nil +} + // Close terminates the container func (lc *LocalstackTestContainer) Close(ctx context.Context) error { return lc.Container.Terminate(ctx) diff --git a/containers/localstack_test.go b/containers/localstack_test.go index 9494e2d..6fca4c7 100644 --- a/containers/localstack_test.go +++ b/containers/localstack_test.go @@ -3,6 +3,8 @@ package containers import ( "context" "io" + "os" + "path/filepath" "strings" "testing" @@ -13,6 +15,10 @@ import ( ) func TestLocalstackTestContainer(t *testing.T) { + if testing.Short() { + t.Skip("skipping Localstack container test in short mode") + } + ctx := context.Background() t.Run("create and cleanup container", func(t *testing.T) { @@ -108,4 +114,96 @@ func TestLocalstackTestContainer(t *testing.T) { require.NoError(t, err) assert.Equal(t, "content2", string(content2)) }) + + t.Run("file operations", func(t *testing.T) { + ls := NewLocalstackTestContainer(ctx, t) + defer func() { require.NoError(t, ls.Close(ctx)) }() + + _, bucketName := ls.MakeS3Connection(ctx, t) + + // create a temp directory and test file + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test-s3-file.txt") + testContent := "Hello S3 world!" + require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0o600)) + + // test SaveFile - upload to S3 + objectKey := "test-object.txt" + err := ls.SaveFile(ctx, testFile, bucketName, objectKey) + require.NoError(t, err, "Failed to upload file to S3") + + // test ListFiles - check if file exists + objects, err := ls.ListFiles(ctx, bucketName, "") + require.NoError(t, err, "Failed to list objects in S3 bucket") + + found := false + for _, obj := range objects { + if *obj.Key == objectKey { + found = true + break + } + } + require.True(t, found, "Uploaded object not found in S3 bucket") + + // test GetFile - download from S3 + downloadedFile := filepath.Join(tempDir, "downloaded-s3-file.txt") + err = ls.GetFile(ctx, bucketName, objectKey, downloadedFile) + require.NoError(t, err, "Failed to download file from S3") + + // verify content + content, err := os.ReadFile(downloadedFile) // #nosec G304 -- Safe file access, path is controlled in test + require.NoError(t, err) + assert.Equal(t, testContent, string(content), "Downloaded content doesn't match original") + + // test DeleteFile - delete from S3 + err = ls.DeleteFile(ctx, bucketName, objectKey) + require.NoError(t, err, "Failed to delete object from S3") + + // verify object was deleted + objects, err = ls.ListFiles(ctx, bucketName, "") + require.NoError(t, err) + + found = false + for _, obj := range objects { + if *obj.Key == objectKey { + found = true + break + } + } + require.False(t, found, "Object should have been deleted from S3") + + // test with prefix + // create multiple test files in different "directories" + prefixPaths := []string{ + "prefix1/file1.txt", + "prefix1/file2.txt", + "prefix2/file1.txt", + } + + for _, path := range prefixPaths { + testFilePath := filepath.Join(tempDir, filepath.Base(path)) + require.NoError(t, os.WriteFile(testFilePath, []byte("Content for "+path), 0o600)) + err := ls.SaveFile(ctx, testFilePath, bucketName, path) + require.NoError(t, err) + } + + // list objects with prefix1 + objects, err = ls.ListFiles(ctx, bucketName, "prefix1/") + require.NoError(t, err) + assert.Len(t, objects, 2, "Should find 2 objects with prefix1/") + + // list objects with prefix2 + objects, err = ls.ListFiles(ctx, bucketName, "prefix2/") + require.NoError(t, err) + assert.Len(t, objects, 1, "Should find 1 object with prefix2/") + + // delete prefix1/file1.txt + err = ls.DeleteFile(ctx, bucketName, "prefix1/file1.txt") + require.NoError(t, err) + + // verify it was deleted + objects, err = ls.ListFiles(ctx, bucketName, "prefix1/") + require.NoError(t, err) + assert.Len(t, objects, 1, "Should find 1 object with prefix1/ after deletion") + }) } diff --git a/containers/ssh.go b/containers/ssh.go index 43aaff2..65ed63e 100644 --- a/containers/ssh.go +++ b/containers/ssh.go @@ -3,14 +3,19 @@ package containers import ( "context" "fmt" + "io" "os" + "path/filepath" + "strings" "testing" "time" "github.com/docker/go-connections/nat" + "github.com/pkg/sftp" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/crypto/ssh" ) // SSHTestContainer is a wrapper around a testcontainers.Container that provides an SSH server @@ -71,6 +76,189 @@ func (sc *SSHTestContainer) Address() string { return fmt.Sprintf("%s:%s", sc.Host, sc.Port.Port()) } +// connect establishes an SSH connection and returns a SFTP client +func (sc *SSHTestContainer) connect(_ context.Context) (sftpClient *sftp.Client, sshClient *ssh.Client, err error) { + key, err := os.ReadFile("testdata/test_ssh_key") + if err != nil { + return nil, nil, fmt.Errorf("failed to read SSH private key: %w", err) + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse SSH private key: %w", err) + } + + config := &ssh.ClientConfig{ + User: sc.User, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + // #nosec G106 -- InsecureIgnoreHostKey is acceptable for test containers + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 30 * time.Second, + } + + addr := sc.Address() + sshClient, err = ssh.Dial("tcp", addr, config) + if err != nil { + return nil, nil, fmt.Errorf("failed to dial SSH server at %s: %w", addr, err) + } + + sftpClient, err = sftp.NewClient(sshClient) + if err != nil { + if closeErr := sshClient.Close(); closeErr != nil { + return nil, nil, fmt.Errorf("failed to create SFTP client: %w and failed to close SSH client: %v", err, closeErr) + } + return nil, nil, fmt.Errorf("failed to create SFTP client: %w", err) + } + + return sftpClient, sshClient, nil +} + +// GetFile downloads a file from the SSH server +func (sc *SSHTestContainer) GetFile(ctx context.Context, remotePath, localPath string) error { + sftpClient, sshClient, err := sc.connect(ctx) + if err != nil { + return fmt.Errorf("failed to connect to SSH server for GetFile: %w", err) + } + defer sftpClient.Close() + defer sshClient.Close() + + localDir := filepath.Dir(localPath) + if err := os.MkdirAll(localDir, 0o750); err != nil { + return fmt.Errorf("failed to create local directory %s: %w", localDir, err) + } + + if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(localDir)) { + return fmt.Errorf("localPath %s attempts to escape from directory %s", localPath, localDir) + } + + // open remote file + remoteFile, err := sftpClient.Open(remotePath) + if err != nil { + return fmt.Errorf("failed to open remote file %s: %w", remotePath, err) + } + defer remoteFile.Close() + + // create local file + localFile, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("failed to create local file %s: %w", localPath, err) + } + defer localFile.Close() + + // copy remote file to local file + if _, err := io.Copy(localFile, remoteFile); err != nil { + return fmt.Errorf("failed to copy file content from %s to %s: %w", remotePath, localPath, err) + } + + return nil +} + +// SaveFile uploads a file to the SSH server +func (sc *SSHTestContainer) SaveFile(ctx context.Context, localPath, remotePath string) error { + sftpClient, sshClient, err := sc.connect(ctx) + if err != nil { + return fmt.Errorf("failed to connect to SSH server for SaveFile: %w", err) + } + defer sftpClient.Close() + defer sshClient.Close() + + if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(filepath.Dir(localPath))) { + return fmt.Errorf("localPath %s attempts to escape from its directory", localPath) + } + + // open local file + localFile, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("failed to open local file %s: %w", localPath, err) + } + defer localFile.Close() + + // create remote directory if it doesn't exist + remoteDir := filepath.Dir(remotePath) + if remoteDir != "." && remoteDir != "/" { + if err := sc.createDirRecursive(sftpClient, remoteDir); err != nil { + return fmt.Errorf("failed to create remote directory %s: %w", remoteDir, err) + } + } + + // create remote file + remoteFile, err := sftpClient.Create(remotePath) + if err != nil { + return fmt.Errorf("failed to create remote file %s: %w", remotePath, err) + } + defer remoteFile.Close() + + // copy local file to remote file + if _, err := io.Copy(remoteFile, localFile); err != nil { + return fmt.Errorf("failed to copy file content from %s to %s: %w", localPath, remotePath, err) + } + + return nil +} + +// create directories recursively +func (sc *SSHTestContainer) createDirRecursive(sftpClient *sftp.Client, remotePath string) error { + parts := strings.Split(strings.Trim(filepath.ToSlash(remotePath), "/"), "/") + if len(parts) == 0 { + return nil + } + + current := "/" + for _, part := range parts { + current = filepath.Join(current, part) + info, err := sftpClient.Stat(current) + if err == nil && info.IsDir() { + continue + } + if err := sftpClient.Mkdir(current); err != nil { + return fmt.Errorf("failed to create directory %s: %w", current, err) + } + } + return nil +} + +// ListFiles lists files in a directory on the SSH server +func (sc *SSHTestContainer) ListFiles(ctx context.Context, remotePath string) ([]os.FileInfo, error) { + sftpClient, sshClient, err := sc.connect(ctx) + if err != nil { + return nil, fmt.Errorf("failed to connect to SSH server for ListFiles: %w", err) + } + defer sftpClient.Close() + defer sshClient.Close() + + // use root directory if path is empty + if remotePath == "" || remotePath == "." { + remotePath = "/" + } + + // get file info + files, err := sftpClient.ReadDir(remotePath) + if err != nil { + return nil, fmt.Errorf("failed to list files in remote path '%s': %w", remotePath, err) + } + + return files, nil +} + +// DeleteFile deletes a file from the SSH server +func (sc *SSHTestContainer) DeleteFile(ctx context.Context, remotePath string) error { + sftpClient, sshClient, err := sc.connect(ctx) + if err != nil { + return fmt.Errorf("failed to connect to SSH server for DeleteFile: %w", err) + } + defer sftpClient.Close() + defer sshClient.Close() + + // delete file + if err := sftpClient.Remove(remotePath); err != nil { + return fmt.Errorf("failed to delete remote file %s: %w", remotePath, err) + } + + return nil +} + // Close terminates the container func (sc *SSHTestContainer) Close(ctx context.Context) error { return sc.Container.Terminate(ctx) diff --git a/containers/ssh_test.go b/containers/ssh_test.go index 59d6b75..8ef8ea5 100644 --- a/containers/ssh_test.go +++ b/containers/ssh_test.go @@ -2,7 +2,9 @@ package containers import ( "context" + "os" "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -10,6 +12,10 @@ import ( ) func TestSSHTestContainer(t *testing.T) { + if testing.Short() { + t.Skip("skipping SSH container test in short mode") + } + ctx := context.Background() t.Run("create and cleanup container", func(t *testing.T) { @@ -54,4 +60,97 @@ func TestSSHTestContainer(t *testing.T) { assert.NotEqual(t, ssh1.Port, ssh2.Port) assert.NotEqual(t, ssh1.Address(), ssh2.Address()) }) + + t.Run("file operations", func(t *testing.T) { + ssh := NewSSHTestContainer(ctx, t) + defer func() { require.NoError(t, ssh.Close(ctx)) }() + + // create a temporary directory for test files + tempDir := t.TempDir() + + // create a test file + testFile := filepath.Join(tempDir, "test-file.txt") + testContent := "Hello SFTP world!" + require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0o600)) + + // test SaveFile - upload file to container + remotePath := "/tmp/test-file.txt" + err := ssh.SaveFile(ctx, testFile, remotePath) + require.NoError(t, err, "Failed to upload file to SSH container") + + // test ListFiles - check if file exists + files, err := ssh.ListFiles(ctx, "/tmp") + require.NoError(t, err, "Failed to list files in SSH container") + + found := false + for _, file := range files { + if file.Name() == "test-file.txt" { + found = true + break + } + } + require.True(t, found, "Uploaded file not found in SSH container") + + // test GetFile - download file from container + downloadedFile := filepath.Join(tempDir, "downloaded-file.txt") + err = ssh.GetFile(ctx, remotePath, downloadedFile) + require.NoError(t, err, "Failed to download file from SSH container") + + // verify content + content, err := os.ReadFile(downloadedFile) // #nosec G304 -- Safe file access, path is controlled in test + require.NoError(t, err) + assert.Equal(t, testContent, string(content), "Downloaded content doesn't match original") + + // test DeleteFile - delete file from container + err = ssh.DeleteFile(ctx, remotePath) + require.NoError(t, err, "Failed to delete file from SSH container") + + // verify file was deleted + files, err = ssh.ListFiles(ctx, "/tmp") + require.NoError(t, err) + + found = false + for _, file := range files { + if file.Name() == "test-file.txt" { + found = true + break + } + } + require.False(t, found, "File should have been deleted from SSH container") + + // test with nested directories + nestedPath := "/tmp/nested/directory/test-nested.txt" + err = ssh.SaveFile(ctx, testFile, nestedPath) + require.NoError(t, err, "Failed to upload file to nested directory") + + // list the nested directory + files, err = ssh.ListFiles(ctx, "/tmp/nested/directory") + require.NoError(t, err) + + found = false + for _, file := range files { + if file.Name() == "test-nested.txt" { + found = true + break + } + } + require.True(t, found, "File should exist in nested directory") + + // delete the nested file + err = ssh.DeleteFile(ctx, nestedPath) + require.NoError(t, err) + + // verify nested file was deleted + files, err = ssh.ListFiles(ctx, "/tmp/nested/directory") + require.NoError(t, err) + + found = false + for _, file := range files { + if file.Name() == "test-nested.txt" { + found = true + break + } + } + require.False(t, found, "Nested file should have been deleted") + }) } diff --git a/go.mod b/go.mod index d908ed6..85ea0f9 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -69,6 +70,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v4 v4.25.3 // indirect diff --git a/go.sum b/go.sum index 6d04919..6e3682d 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,7 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -100,6 +101,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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= @@ -132,6 +135,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -143,9 +148,12 @@ github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5Bdj github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00= @@ -193,23 +201,41 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -224,16 +250,35 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= @@ -243,6 +288,9 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=