From fdbf0cd23373443c316380ef5cc40353f409710b Mon Sep 17 00:00:00 2001 From: Jonas Kaninda Date: Sat, 21 Feb 2026 08:11:49 +0100 Subject: [PATCH] feat: add gsc and go_storage package --- .github/workflows/lint.yml | 6 +- .golangci.yml | 51 +++--- azure.go | 155 ++++++++++++++++ ftp.go | 251 ++++++++++++++++++++++++++ gcs.go | 156 +++++++++++++++++ go.mod | 55 +++++- go.sum | 142 +++++++++++++-- local.go | 348 ++++++++++++++++++++++++++++++++++++ s3.go | 187 ++++++++++++++++++++ sftp.go | 350 +++++++++++++++++++++++++++++++++++++ storage.go | 140 +++++++++++++++ storage_test.go | 41 +++++ 12 files changed, 1835 insertions(+), 47 deletions(-) create mode 100644 azure.go create mode 100644 ftp.go create mode 100644 gcs.go create mode 100644 local.go create mode 100644 s3.go create mode 100644 sftp.go create mode 100644 storage.go create mode 100644 storage_test.go diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f40d365..92b0dd9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,9 +15,9 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '~1.22' + go-version: '~1.24' - name: Run linter - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.61 + version: v2.1.2 diff --git a/.golangci.yml b/.golangci.yml index ae8a9cf..fabeaaf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,44 +1,47 @@ +version: "2" run: - timeout: 5m allow-parallel-runners: true - -issues: - # don't skip warning about doc comments - # don't exclude the default set of lint - exclude-use-default: false - # restore some of the defaults - # (fill in the rest as needed) - exclude-rules: - - path: "internal/*" - linters: - - dupl - - lll - - goimports linters: - disable-all: true + default: none enable: + - copyloopvar - dupl - errcheck - - copyloopvar - ginkgolinter - goconst - gocyclo - - gofmt - - gosimple - govet - ineffassign - # - lll + #- lll - misspell - nakedret - prealloc - revive - staticcheck - - typecheck - unconvert - unparam - unused - -linters-settings: - revive: + settings: + revive: + rules: + - name: comment-spacings + exclusions: + generated: lax rules: - - name: comment-spacings + - linters: + - dupl + - lll + path: /* + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/azure.go b/azure.go new file mode 100644 index 0000000..ef34143 --- /dev/null +++ b/azure.go @@ -0,0 +1,155 @@ +package go_storage + +import ( + "context" + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" +) + +// AzureStorage implements Storage interface for Azure Blob Storage. +type AzureStorage struct { + client *azblob.Client + container string +} + +// NewAzureStorage creates a new Azure Blob Storage instance. +func NewAzureStorage(config Config) (*AzureStorage, error) { + if err := validateConfig(config, map[string]string{ + "container": config.AzureContainer, + }); err != nil { + return nil, fmt.Errorf("azure: %w", err) + } + + // Try to get credentials from environment if not provided + account := config.AzureAccount + if account == "" { + account = os.Getenv("AZURE_STORAGE_ACCOUNT") + } + + key := config.AzureKey + if key == "" { + key = os.Getenv("AZURE_STORAGE_KEY") + } + + endpoint := config.AzureEndpoint + if endpoint == "" { + endpoint = "blob.core.windows.net" + } + + var client *azblob.Client + var err error + + if account == "" { + // Anonymous access + client, err = azblob.NewClientWithNoCredential(endpoint, nil) + if err != nil { + return nil, fmt.Errorf("azure: failed to create anonymous client: %w", err) + } + } else { + // Authenticated access + credential, err := azblob.NewSharedKeyCredential(account, key) + if err != nil { + return nil, fmt.Errorf("azure: failed to create credentials: %w", err) + } + + url := fmt.Sprintf("https://%s.%s", account, endpoint) + + client, err = azblob.NewClientWithSharedKeyCredential(url, credential, nil) + if err != nil { + return nil, fmt.Errorf("azure: failed to create client: %w", err) + } + } + + return &AzureStorage{ + client: client, + container: config.AzureContainer, + }, nil +} + +// Upload uploads a file to Azure Blob Storage. +func (a *AzureStorage) Upload(ctx context.Context, localPath string, remotePath string) error { + file, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("azure: failed to open local file: %w", err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + fmt.Printf("azure: failed to close file: %v\n", err) + } + }(file) + + _, err = a.client.UploadFile(ctx, a.container, normalizePathSeparators(remotePath), file, nil) + if err != nil { + return fmt.Errorf("azure: failed to upload to container %s: %w", a.container, err) + } + + return nil +} + +// Download downloads a file from Azure Blob Storage. +func (a *AzureStorage) Download(ctx context.Context, remotePath string, localPath string) error { + file, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("azure: failed to create local file: %w", err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + fmt.Printf("azure: failed to close file: %v\n", err) + } + }(file) + + _, err = a.client.DownloadFile(ctx, a.container, normalizePathSeparators(remotePath), file, nil) + if err != nil { + return fmt.Errorf("azure: failed to download from container %s: %w", a.container, err) + } + + return nil +} + +// List lists blobs in Azure Blob Storage with the given prefix. +func (a *AzureStorage) List(ctx context.Context, prefix string) ([]Item, error) { + normalizedPrefix := normalizePathSeparators(prefix) + + pager := a.client.NewListBlobsFlatPager(a.container, &azblob.ListBlobsFlatOptions{ + Prefix: &normalizedPrefix, + }) + + items := make([]Item, 0) + + for pager.More() { + resp, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("azure: failed to list blobs in container %s: %w", a.container, err) + } + + for _, blob := range resp.Segment.BlobItems { + items = append(items, Item{ + Key: *blob.Name, + ModifiedTime: *blob.Properties.LastModified, + Size: *blob.Properties.ContentLength, + IsDirectory: false, + }) + } + } + + return items, nil +} + +// Remove deletes a blob from Azure Blob Storage. +func (a *AzureStorage) Remove(ctx context.Context, remotePath string) error { + _, err := a.client.DeleteBlob(ctx, a.container, normalizePathSeparators(remotePath), nil) + if err != nil { + return fmt.Errorf("azure: failed to remove blob from container %s: %w", a.container, err) + } + + return nil +} + +// Close releases resources. +func (a *AzureStorage) Close() error { + return nil +} diff --git a/ftp.go b/ftp.go new file mode 100644 index 0000000..1754c0e --- /dev/null +++ b/ftp.go @@ -0,0 +1,251 @@ +package go_storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jlaffaye/ftp" +) + +// FTPStorage implements Storage interface for FTP. +type FTPStorage struct { + host string + port string + baseDir string + client *ftp.ServerConn +} + +// NewFTPStorage creates a new FTP storage instance. +func NewFTPStorage(config Config) (*FTPStorage, error) { + if err := validateConfig(config, map[string]string{ + "host": config.FTPHost, + }); err != nil { + return nil, fmt.Errorf("ftp: %w", err) + } + + port := config.FTPPort + if port == "" { + port = "21" + } + + // Connect to FTP server + hostPort := fmt.Sprintf("%s:%s", config.FTPHost, port) + conn, err := ftp.Dial(hostPort, ftp.DialWithTimeout(30*time.Second)) + if err != nil { + return nil, fmt.Errorf("ftp: failed to connect to %s: %w", hostPort, err) + } + + // Login + if err := conn.Login(config.FTPUsername, config.FTPPassword); err != nil { + conn.Quit() + return nil, fmt.Errorf("ftp: login failed: %w", err) + } + + return &FTPStorage{ + host: config.FTPHost, + port: port, + baseDir: config.FTPDirectory, + client: conn, + }, nil +} + +// Upload uploads a file via FTP. +func (f *FTPStorage) Upload(ctx context.Context, localPath string, remotePath string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + src, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("ftp: failed to open local file: %w", err) + } + defer src.Close() + + fullRemotePath := filepath.Join(f.baseDir, remotePath) + fullRemotePath = normalizePathSeparators(fullRemotePath) + + // Create parent directory if needed + remoteDir := filepath.Dir(fullRemotePath) + remoteDir = normalizePathSeparators(remoteDir) + + if remoteDir != "." && remoteDir != "/" { + f.ensureDirectory(remoteDir) + + } + + // Upload file + if err := f.client.Stor(fullRemotePath, src); err != nil { + return fmt.Errorf("ftp: failed to upload file %s: %w", fullRemotePath, err) + } + + return nil +} + +// Download downloads a file via FTP. +func (f *FTPStorage) Download(ctx context.Context, remotePath string, localPath string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + fullRemotePath := filepath.Join(f.baseDir, remotePath) + fullRemotePath = normalizePathSeparators(fullRemotePath) + + // Get the file from FTP server + resp, err := f.client.Retr(fullRemotePath) + if err != nil { + return fmt.Errorf("ftp: failed to retrieve file %s: %w", fullRemotePath, err) + } + defer resp.Close() + + // Create local file + dst, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("ftp: failed to create local file: %w", err) + } + defer dst.Close() + + // Copy data + if _, err := io.Copy(dst, resp); err != nil { + return fmt.Errorf("ftp: failed to transfer data: %w", err) + } + + return nil +} + +// List lists files via FTP with the given prefix. +func (f *FTPStorage) List(ctx context.Context, prefix string) ([]Item, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + items := make([]Item, 0) + + // Normalize the base directory + baseDir := normalizePathSeparators(f.baseDir) + if baseDir == "" { + baseDir = "/" + } + + // Walk the directory + if err := f.walkDirectory(baseDir, prefix, &items); err != nil { + return nil, err + } + + return items, nil +} + +// walkDirectory recursively walks FTP directory structure. +func (f *FTPStorage) walkDirectory(dir string, prefix string, items *[]Item) error { + entries, err := f.client.List(dir) + if err != nil { + return fmt.Errorf("ftp: failed to list directory %s: %w", dir, err) + } + + for _, entry := range entries { + // Get relative path + var relPath string + if f.baseDir == "" || f.baseDir == "/" { + relPath = filepath.Join(strings.TrimPrefix(dir, "/"), entry.Name) + } else { + fullPath := filepath.Join(dir, entry.Name) + relPath = strings.TrimPrefix(fullPath, f.baseDir) + relPath = strings.TrimPrefix(relPath, "/") + } + + // Normalize separators for comparison + relPath = normalizePathSeparators(relPath) + + // Skip if doesn't match prefix + if prefix != "" && !strings.HasPrefix(relPath, prefix) { + // For directories, we might need to walk them to find matching files + if entry.Type == ftp.EntryTypeFolder { + fullPath := filepath.Join(dir, entry.Name) + fullPath = normalizePathSeparators(fullPath) + if err := f.walkDirectory(fullPath, prefix, items); err != nil { + // Log but don't fail on subdirectory errors + continue + } + } + continue + } + + item := Item{ + Key: relPath, + ModifiedTime: entry.Time, + Size: int64(entry.Size), + IsDirectory: entry.Type == ftp.EntryTypeFolder, + } + *items = append(*items, item) + + // Recursively walk directories + if entry.Type == ftp.EntryTypeFolder { + fullPath := filepath.Join(dir, entry.Name) + fullPath = normalizePathSeparators(fullPath) + if err := f.walkDirectory(fullPath, prefix, items); err != nil { + // Log but don't fail on subdirectory errors + continue + } + } + } + + return nil +} + +// Remove deletes a file via FTP. +func (f *FTPStorage) Remove(ctx context.Context, remotePath string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + fullRemotePath := filepath.Join(f.baseDir, remotePath) + fullRemotePath = normalizePathSeparators(fullRemotePath) + + if err := f.client.Delete(fullRemotePath); err != nil { + return fmt.Errorf("ftp: failed to remove %s: %w", fullRemotePath, err) + } + + return nil +} + +// Close releases resources. +func (f *FTPStorage) Close() error { + if err := f.client.Quit(); err != nil { + return fmt.Errorf("ftp: failed to close connection: %w", err) + } + return nil +} + +// ensureDirectory creates a directory path if it doesn't exist. +func (f *FTPStorage) ensureDirectory(path string) { + parts := strings.Split(path, "/") + current := "" + + for _, part := range parts { + if part == "" { + continue + } + + if current == "" { + current = part + } else { + current = current + "/" + part + } + + // Try to create directory (ignore errors if it already exists) + _ = f.client.MakeDir(current) + } + +} diff --git a/gcs.go b/gcs.go new file mode 100644 index 0000000..88f2f11 --- /dev/null +++ b/gcs.go @@ -0,0 +1,156 @@ +package go_storage + +import ( + "context" + "fmt" + "io" + "os" + + "cloud.google.com/go/storage" + "google.golang.org/api/iterator" + "google.golang.org/api/option" +) + +// GCSStorage implements Storage interface for Google Cloud Storage. +type GCSStorage struct { + client *storage.Client + bucket string +} + +// NewGCSStorage creates a new Google Cloud Storage instance. +func NewGCSStorage(config Config) (*GCSStorage, error) { + if err := validateConfig(config, map[string]string{ + "bucket": config.GCSBucket, + }); err != nil { + return nil, fmt.Errorf("gcs: %w", err) + } + + options := make([]option.ClientOption, 0) + + if config.GCSEndpoint != "" { + options = append(options, option.WithEndpoint(config.GCSEndpoint)) + } + + if config.GCSCredentialsFile != "" { + options = append(options, option.WithCredentialsFile(config.GCSCredentialsFile)) + } + + client, err := storage.NewClient(context.Background(), options...) + if err != nil { + return nil, fmt.Errorf("gcs: failed to create client: %w", err) + } + + return &GCSStorage{ + client: client, + bucket: config.GCSBucket, + }, nil +} + +// Upload uploads a file to GCS. +func (g *GCSStorage) Upload(ctx context.Context, localPath string, remotePath string) error { + file, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("gcs: failed to open local file: %w", err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + fmt.Printf("gcs: failed to close file: %v\n", err) + } + }(file) + + obj := g.client.Bucket(g.bucket).Object(normalizePathSeparators(remotePath)).NewWriter(ctx) + defer func(obj *storage.Writer) { + err = obj.Close() + if err != nil { + fmt.Printf("gcs: failed to close object writer: %v\n", err) + } + }(obj) + + if _, err := io.Copy(obj, file); err != nil { + return fmt.Errorf("gcs: failed to write data: %w", err) + } + + if err = obj.Close(); err != nil { + return fmt.Errorf("gcs: failed to finalize upload: %w", err) + } + + return nil +} + +// Download downloads a file from GCS. +func (g *GCSStorage) Download(ctx context.Context, remotePath string, localPath string) error { + file, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("gcs: failed to create local file: %w", err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + fmt.Printf("gcs: failed to close file: %v\n", err) + } + }(file) + + obj, err := g.client.Bucket(g.bucket).Object(normalizePathSeparators(remotePath)).NewReader(ctx) + if err != nil { + return fmt.Errorf("gcs: failed to create reader: %w", err) + } + defer func(obj *storage.Reader) { + err = obj.Close() + if err != nil { + fmt.Printf("gcs: failed to close object reader: %v\n", err) + } + }(obj) + + if _, err = io.Copy(file, obj); err != nil { + return fmt.Errorf("gcs: failed to read data: %w", err) + } + + return nil +} + +// List lists objects in GCS with the given prefix. +func (g *GCSStorage) List(ctx context.Context, prefix string) ([]Item, error) { + items := make([]Item, 0) + + query := &storage.Query{ + Prefix: normalizePathSeparators(prefix), + } + + it := g.client.Bucket(g.bucket).Objects(ctx, query) + + for { + attrs, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("gcs: failed to iterate objects: %w", err) + } + + items = append(items, Item{ + Key: attrs.Name, + ModifiedTime: attrs.Updated, + Size: attrs.Size, + IsDirectory: false, + }) + } + + return items, nil +} + +// Remove deletes an object from GCS. +func (g *GCSStorage) Remove(ctx context.Context, remotePath string) error { + obj := g.client.Bucket(g.bucket).Object(normalizePathSeparators(remotePath)) + + if err := obj.Delete(ctx); err != nil { + return fmt.Errorf("gcs: failed to remove object from bucket %s: %w", g.bucket, err) + } + + return nil +} + +// Close releases resources. +func (g *GCSStorage) Close() error { + return g.client.Close() +} diff --git a/go.mod b/go.mod index 2918cf8..e0f5ac1 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,67 @@ module github.com/jkaninda/go-storage -go 1.23.0 +go 1.24.0 require ( + cloud.google.com/go/storage v1.60.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 github.com/aws/aws-sdk-go v1.55.7 github.com/bramvdbogaerde/go-scp v1.5.0 github.com/jlaffaye/ftp v0.2.0 - golang.org/x/crypto v0.38.0 + github.com/pkg/sftp v1.13.10 + golang.org/x/crypto v0.47.0 + google.golang.org/api v0.267.0 ) require ( + cel.dev/expr v0.24.0 // 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 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.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/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect + github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // 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/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.17.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.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 + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.35.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-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index d7a4448..9e1344f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,25 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +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/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8= +cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= @@ -10,17 +32,58 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 h1:lhZdRq7TIx0GJQvSy github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+effbARHMQjgOKA2AYvcohNm7KEt42mSV8= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.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/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM= github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= +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-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +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.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/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.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= 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= @@ -32,25 +95,74 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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/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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +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/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI= +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= +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/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.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.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.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/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/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/local.go b/local.go new file mode 100644 index 0000000..1311e13 --- /dev/null +++ b/local.go @@ -0,0 +1,348 @@ +package go_storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// LocalStorage implements the Storage interface using the local filesystem. +// It is safe for concurrent use as it relies on filesystem operations which +// are handled by the OS. +type LocalStorage struct { + basePath string +} + +// NewLocalStorage creates a new LocalStorage instance with the given base directory. +// The base directory will be created if it doesn't exist. +func NewLocalStorage(basePath string) (*LocalStorage, error) { + absPath, err := filepath.Abs(basePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve absolute path: %w", err) + } + + if err := os.MkdirAll(absPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create base directory: %w", err) + } + + return &LocalStorage{ + basePath: absPath, + }, nil +} + +// resolvePath safely resolves a remote path to an absolute local path, +// ensuring it stays within the base directory. +func (s *LocalStorage) resolvePath(remotePath string) (string, error) { + // Clean the path to remove any .. or . components + cleanPath := filepath.Clean(remotePath) + + // Remove leading slashes to make it relative + cleanPath = strings.TrimPrefix(cleanPath, string(filepath.Separator)) + + // Join with base path + fullPath := filepath.Join(s.basePath, cleanPath) + + // Verify the resolved path is still within basePath + if !strings.HasPrefix(fullPath, s.basePath) { + return "", fmt.Errorf("path traversal attempt detected: %s", remotePath) + } + + return fullPath, nil +} + +// Upload uploads a local file to the storage with the given target name. +func (s *LocalStorage) Upload(ctx context.Context, localPath string, remotePath string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + targetPath, err := s.resolvePath(remotePath) + if err != nil { + return err + } + + // Open source file + srcFile, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + // Create parent directories if needed + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + + // Create destination file + dstFile, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer dstFile.Close() + + // Copy with context cancellation checks + buf := make([]byte, 32*1024) // 32KB buffer + for { + select { + case <-ctx.Done(): + // Clean up partial file on cancellation + os.Remove(targetPath) + return ctx.Err() + default: + } + + n, readErr := srcFile.Read(buf) + if n > 0 { + if _, writeErr := dstFile.Write(buf[:n]); writeErr != nil { + os.Remove(targetPath) + return fmt.Errorf("failed to write to destination: %w", writeErr) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + os.Remove(targetPath) + return fmt.Errorf("failed to read source file: %w", readErr) + } + } + + return nil +} + +// Download downloads a file from storage to a local path. +func (s *LocalStorage) Download(ctx context.Context, remotePath string, localPath string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + sourcePath, err := s.resolvePath(remotePath) + if err != nil { + return err + } + + // Open source file + srcFile, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("failed to open remote file: %w", err) + } + defer srcFile.Close() + + // Create parent directories for local path if needed + if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { + return fmt.Errorf("failed to create local parent directories: %w", err) + } + + // Create destination file + dstFile, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("failed to create local file: %w", err) + } + defer dstFile.Close() + + // Copy with context cancellation checks + buf := make([]byte, 32*1024) // 32KB buffer + for { + select { + case <-ctx.Done(): + // Clean up partial file on cancellation + os.Remove(localPath) + return ctx.Err() + default: + } + + n, readErr := srcFile.Read(buf) + if n > 0 { + if _, writeErr := dstFile.Write(buf[:n]); writeErr != nil { + os.Remove(localPath) + return fmt.Errorf("failed to write to local file: %w", writeErr) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + os.Remove(localPath) + return fmt.Errorf("failed to read remote file: %w", readErr) + } + } + + return nil +} + +// List returns all items in the storage that match the given prefix. +func (s *LocalStorage) List(ctx context.Context, prefix string) ([]Item, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + var items []Item + + // Clean and resolve the prefix path + searchPath := s.basePath + cleanPrefix := strings.TrimPrefix(filepath.Clean(prefix), string(filepath.Separator)) + + if cleanPrefix != "" && cleanPrefix != "." { + searchPath = filepath.Join(s.basePath, cleanPrefix) + } + + // Check if the search path exists + info, err := os.Stat(searchPath) + if os.IsNotExist(err) { + // If the exact path doesn't exist, try to find files with this prefix + // by walking the parent directory + parentDir := filepath.Dir(searchPath) + baseName := filepath.Base(searchPath) + + if _, err := os.Stat(parentDir); os.IsNotExist(err) { + return items, nil // Return empty list if parent doesn't exist + } + + entries, err := os.ReadDir(parentDir) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + for _, entry := range entries { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if strings.HasPrefix(entry.Name(), baseName) { + fullPath := filepath.Join(parentDir, entry.Name()) + item, err := s.fileInfoToItem(fullPath) + if err != nil { + continue // Skip files we can't stat + } + items = append(items, item) + } + } + return items, nil + } + + if err != nil { + return nil, fmt.Errorf("failed to stat path: %w", err) + } + + // If it's a file, return just that file + if !info.IsDir() { + item, err := s.fileInfoToItem(searchPath) + if err != nil { + return nil, err + } + return []Item{item}, nil + } + + // Walk the directory + err = filepath.WalkDir(searchPath, func(path string, d os.DirEntry, err error) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err != nil { + return nil // Skip entries with errors + } + + item, err := s.fileInfoToItem(path) + if err != nil { + return nil // Skip files we can't stat + } + items = append(items, item) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory: %w", err) + } + + return items, nil +} + +// fileInfoToItem converts a file path to an Item. +func (s *LocalStorage) fileInfoToItem(path string) (Item, error) { + info, err := os.Stat(path) + if err != nil { + return Item{}, err + } + + // Get relative path from base + relPath, err := filepath.Rel(s.basePath, path) + if err != nil { + relPath = path + } + + // Normalize to forward slashes for consistency + relPath = filepath.ToSlash(relPath) + + size := info.Size() + if info.IsDir() { + size = 0 + } + + return Item{ + Key: relPath, + ModifiedTime: info.ModTime(), + Size: size, + IsDirectory: info.IsDir(), + }, nil +} + +// Remove deletes a file from storage. +func (s *LocalStorage) Remove(ctx context.Context, remotePath string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + targetPath, err := s.resolvePath(remotePath) + if err != nil { + return err + } + + // Check if file exists + info, err := os.Stat(targetPath) + if os.IsNotExist(err) { + return fmt.Errorf("file not found: %s", remotePath) + } + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + // Remove file or directory + if info.IsDir() { + if err := os.RemoveAll(targetPath); err != nil { + return fmt.Errorf("failed to remove directory: %w", err) + } + } else { + if err := os.Remove(targetPath); err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + } + + return nil +} + +// Close releases any resources held by the storage implementation. +// For LocalStorage, this is a no-op as there are no persistent resources. +func (s *LocalStorage) Close() error { + return nil +} + +// BasePath returns the base directory path of the storage. +func (s *LocalStorage) BasePath() string { + return s.basePath +} diff --git a/s3.go b/s3.go new file mode 100644 index 0000000..32d5571 --- /dev/null +++ b/s3.go @@ -0,0 +1,187 @@ +package go_storage + +import ( + "context" + "fmt" + "os" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" +) + +// S3Storage implements Storage interface for Amazon S3. +type S3Storage struct { + session *session.Session + bucket string + region string +} + +// NewS3Storage creates a new S3 storage instance. +func NewS3Storage(config Config) (*S3Storage, error) { + if err := validateConfig(config, map[string]string{ + "bucket": config.S3Bucket, + }); err != nil { + return nil, fmt.Errorf("s3: %w", err) + } + + awsConfig := aws.NewConfig() + + if config.S3Region != "" { + awsConfig = awsConfig.WithRegion(config.S3Region) + } + + if config.S3KeyID != "" && config.S3Secret != "" { + awsConfig = awsConfig.WithCredentials( + credentials.NewStaticCredentials(config.S3KeyID, config.S3Secret, ""), + ) + } + + if config.S3Endpoint != "" { + awsConfig = awsConfig.WithEndpoint(config.S3Endpoint) + } + + if config.S3ForcePath { + awsConfig = awsConfig.WithS3ForcePathStyle(true) + } + + if config.S3DisableTLS { + awsConfig = awsConfig.WithDisableSSL(true) + } + + sessionOpts := session.Options{ + Config: *awsConfig, + SharedConfigState: session.SharedConfigEnable, + } + + if config.S3Profile != "" { + sessionOpts.Profile = config.S3Profile + } + + sess, err := session.NewSessionWithOptions(sessionOpts) + if err != nil { + return nil, fmt.Errorf("s3: failed to create session: %w", err) + } + + return &S3Storage{ + session: sess, + bucket: config.S3Bucket, + region: config.S3Region, + }, nil +} + +// Upload uploads a file to S3. +func (s *S3Storage) Upload(ctx context.Context, localPath string, remotePath string) error { + file, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("s3: failed to open local file: %w", err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + fmt.Printf("s3: warning: failed to close file: %v\n", err) + } + }(file) + + uploader := s3manager.NewUploader(s.session) + + _, err = uploader.UploadWithContext(ctx, &s3manager.UploadInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(normalizePathSeparators(remotePath)), + Body: file, + }) + + if err != nil { + return fmt.Errorf("s3: failed to upload to bucket %s: %w", s.bucket, err) + } + + return nil +} + +// Download downloads a file from S3. +func (s *S3Storage) Download(ctx context.Context, remotePath string, localPath string) error { + file, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("s3: failed to create local file: %w", err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + fmt.Printf("s3: warning: failed to close file: %v\n", err) + } + }(file) + + downloader := s3manager.NewDownloader(s.session) + + _, err = downloader.DownloadWithContext(ctx, file, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(normalizePathSeparators(remotePath)), + }) + + if err != nil { + return fmt.Errorf("s3: failed to download from bucket %s: %w", s.bucket, err) + } + + return nil +} + +// List lists objects in S3 with the given prefix. +func (s *S3Storage) List(ctx context.Context, prefix string) ([]Item, error) { + svc := s3.New(s.session) + items := make([]Item, 0) + + var continuationToken *string + + for { + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucket), + Prefix: aws.String(normalizePathSeparators(prefix)), + ContinuationToken: continuationToken, + } + + resp, err := svc.ListObjectsV2WithContext(ctx, input) + if err != nil { + return nil, fmt.Errorf("s3: failed to list objects in bucket %s: %w", s.bucket, err) + } + + for _, obj := range resp.Contents { + items = append(items, Item{ + Key: *obj.Key, + ModifiedTime: *obj.LastModified, + Size: *obj.Size, + IsDirectory: false, + }) + } + + if resp.IsTruncated == nil || !*resp.IsTruncated { + break + } + + continuationToken = resp.NextContinuationToken + } + + return items, nil +} + +// Remove deletes an object from S3. +func (s *S3Storage) Remove(ctx context.Context, remotePath string) error { + svc := s3.New(s.session) + + _, err := svc.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(normalizePathSeparators(remotePath)), + }) + + if err != nil { + return fmt.Errorf("s3: failed to remove object from bucket %s: %w", s.bucket, err) + } + + return nil +} + +// Close releases resources. +func (s *S3Storage) Close() error { + return nil +} diff --git a/sftp.go b/sftp.go new file mode 100644 index 0000000..78bbb98 --- /dev/null +++ b/sftp.go @@ -0,0 +1,350 @@ +package go_storage + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/pkg/sftp" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +// SFTPStorage implements Storage interface for SFTP. +type SFTPStorage struct { + host string + baseDir string + sshClient *ssh.Client + client *sftp.Client +} + +// NewSFTPStorage creates a new SFTP storage instance. +func NewSFTPStorage(config Config) (*SFTPStorage, error) { + if err := validateConfig(config, map[string]string{ + "host": config.SFTPHost, + }); err != nil { + return nil, fmt.Errorf("sftp: %w", err) + } + + port := config.SFTPPort + if port == "" { + port = "22" + } + + username := config.SFTPUsername + if username == "" { + currentUser, err := user.Current() + if err != nil { + return nil, fmt.Errorf("sftp: failed to get current user: %w", err) + } + username = currentUser.Username + } + + password := config.SFTPPassword + if password == "" { + password = os.Getenv("PGBK_SSH_PASS") + } + + // Build authentication methods + authMethods := make([]ssh.AuthMethod, 0) + + // Password authentication (if no identity file is specified) + if config.SFTPIdentityFile == "" && password != "" { + authMethods = append(authMethods, ssh.Password(password)) + } + + // Public key authentication + signers, err := getSSHSigners(config.SFTPIdentityFile, password) + if err != nil { + return nil, fmt.Errorf("sftp: %w", err) + } + if len(signers) > 0 { + authMethods = append(authMethods, ssh.PublicKeys(signers...)) + } + + // Build SSH client config + sshConfig := &ssh.ClientConfig{ + User: username, + Auth: authMethods, + HostKeyCallback: getHostKeyCallback(config.SFTPIgnoreKnownHosts), + } + + // Connect to SSH server + hostPort := fmt.Sprintf("%s:%s", config.SFTPHost, port) + sshClient, err := ssh.Dial("tcp", hostPort, sshConfig) + if err != nil { + return nil, fmt.Errorf("sftp: failed to connect to %s: %w", hostPort, err) + } + + // Open SFTP session + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + sshClient.Close() + return nil, fmt.Errorf("sftp: failed to open sftp session: %w", err) + } + + baseDir := config.SFTPDirectory + if baseDir == "" { + wd, err := sftpClient.Getwd() + if err != nil { + sftpClient.Close() + sshClient.Close() + return nil, fmt.Errorf("sftp: failed to get working directory: %w", err) + } + baseDir = wd + } + + return &SFTPStorage{ + host: config.SFTPHost, + baseDir: baseDir, + sshClient: sshClient, + client: sftpClient, + }, nil +} + +// Upload uploads a file via SFTP. +func (s *SFTPStorage) Upload(ctx context.Context, localPath string, remotePath string) error { + src, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("sftp: failed to open local file: %w", err) + } + defer src.Close() + + fullRemotePath := filepath.Join(s.baseDir, remotePath) + fullRemotePath = normalizePathSeparators(fullRemotePath) + + // Create parent directory if needed + remoteDir := filepath.Dir(fullRemotePath) + remoteDir = normalizePathSeparators(remoteDir) + + if remoteDir != "." && remoteDir != "/" { + if err := s.client.MkdirAll(remoteDir); err != nil { + return fmt.Errorf("sftp: failed to create directory %s: %w", remoteDir, err) + } + } + + dst, err := s.client.Create(fullRemotePath) + if err != nil { + return fmt.Errorf("sftp: failed to create remote file %s: %w", fullRemotePath, err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("sftp: failed to transfer data: %w", err) + } + + return nil +} + +// Download downloads a file via SFTP. +func (s *SFTPStorage) Download(ctx context.Context, remotePath string, localPath string) error { + dst, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("sftp: failed to create local file: %w", err) + } + defer dst.Close() + + fullRemotePath := filepath.Join(s.baseDir, remotePath) + fullRemotePath = normalizePathSeparators(fullRemotePath) + + src, err := s.client.Open(fullRemotePath) + if err != nil { + return fmt.Errorf("sftp: failed to open remote file %s: %w", fullRemotePath, err) + } + defer src.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("sftp: failed to transfer data: %w", err) + } + + return nil +} + +// List lists files via SFTP with the given prefix. +func (s *SFTPStorage) List(ctx context.Context, prefix string) ([]Item, error) { + items := make([]Item, 0) + + baseDir := normalizePathSeparators(s.baseDir) + + walker := s.client.Walk(baseDir) + for walker.Step() { + if err := walker.Err(); err != nil { + return nil, fmt.Errorf("sftp: walk error: %w", err) + } + + // Get relative path + path := walker.Path() + relPath, err := filepath.Rel(baseDir, path) + if err != nil { + continue + } + + // Normalize separators for comparison + relPath = normalizePathSeparators(relPath) + + if !strings.HasPrefix(relPath, prefix) { + continue + } + + stat := walker.Stat() + items = append(items, Item{ + Key: relPath, + ModifiedTime: stat.ModTime(), + Size: stat.Size(), + IsDirectory: stat.IsDir(), + }) + } + + return items, nil +} + +// Remove deletes a file via SFTP. +func (s *SFTPStorage) Remove(ctx context.Context, remotePath string) error { + fullRemotePath := filepath.Join(s.baseDir, remotePath) + fullRemotePath = normalizePathSeparators(fullRemotePath) + + if err := s.client.Remove(fullRemotePath); err != nil { + return fmt.Errorf("sftp: failed to remove %s: %w", fullRemotePath, err) + } + + return nil +} + +// Close releases resources. +func (s *SFTPStorage) Close() error { + if err := s.client.Close(); err != nil { + s.sshClient.Close() + return err + } + return s.sshClient.Close() +} + +// getSSHSigners returns SSH signers from identity file and SSH agent. +func getSSHSigners(identityFile string, passphrase string) ([]ssh.Signer, error) { + signers := make([]ssh.Signer, 0) + + // Load identity file if provided + if identityFile != "" { + path, err := expandHomeDir(identityFile) + if err != nil { + return nil, fmt.Errorf("failed to expand identity file path: %w", err) + } + + keyData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read identity file %s: %w", path, err) + } + + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + var passErr *ssh.PassphraseMissingError + if errors.As(err, &passErr) { + signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase)) + if err != nil { + return nil, fmt.Errorf("failed to decrypt identity file: %w", err) + } + } else { + return nil, fmt.Errorf("failed to parse identity file: %w", err) + } + } + + signers = append(signers, signer) + } + + // Try to get keys from SSH agent + socket := os.Getenv("SSH_AUTH_SOCK") + if socket != "" { + conn, err := net.Dial("unix", socket) + if err == nil { + agentClient := agent.NewClient(conn) + agentSigners, err := agentClient.Signers() + if err == nil { + signers = append(signers, agentSigners...) + } + } + } + + return signers, nil +} + +// getHostKeyCallback returns appropriate host key callback. +func getHostKeyCallback(ignoreHostKey bool) ssh.HostKeyCallback { + if ignoreHostKey { + return ssh.InsecureIgnoreHostKey() + } + + knownHostsFiles := make([]string, 0) + for _, p := range []string{"/etc/ssh/ssh_known_hosts", "~/.ssh/known_hosts"} { + path, err := expandHomeDir(p) + if err != nil { + continue + } + + if _, err := os.Stat(path); err == nil { + knownHostsFiles = append(knownHostsFiles, path) + } + } + + if len(knownHostsFiles) == 0 { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return fmt.Errorf("no known_hosts files found for host key verification") + } + } + + callback, err := knownhosts.New(knownHostsFiles...) + if err != nil { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return fmt.Errorf("failed to load known_hosts: %w", err) + } + } + + return callback +} + +// expandHomeDir expands ~ in file paths to home directory. +func expandHomeDir(path string) (string, error) { + if !strings.HasPrefix(path, "~") { + return filepath.Clean(path), nil + } + + parts := strings.SplitN(path, "/", 2) + username := strings.TrimPrefix(parts[0], "~") + + var homeDir string + var err error + + if username == "" { + homeDir, err = os.UserHomeDir() + if err != nil || homeDir == "" { + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + homeDir = currentUser.HomeDir + } + } else { + userInfo, err := user.Lookup(username) + if err != nil { + return "", fmt.Errorf("failed to lookup user %s: %w", username, err) + } + homeDir = userInfo.HomeDir + } + + if homeDir == "" { + return "", fmt.Errorf("empty home directory") + } + + if len(parts) == 1 { + return homeDir, nil + } + + return filepath.Join(homeDir, parts[1]), nil +} diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..978cc03 --- /dev/null +++ b/storage.go @@ -0,0 +1,140 @@ +package go_storage + +import ( + "context" + "fmt" + "os" + "strings" + "time" +) + +// Storage defines the interface for remote file storage operations. +// All implementations must be safe for concurrent use. +type Storage interface { + // Upload uploads a local file to the remote storage with the given target name. + Upload(ctx context.Context, localPath string, remotePath string) error + + // Download downloads a file from remote storage to a local path. + Download(ctx context.Context, remotePath string, localPath string) error + + // List returns all items in the storage that match the given prefix. + List(ctx context.Context, prefix string) ([]Item, error) + + // Remove deletes a file from remote storage. + Remove(ctx context.Context, remotePath string) error + + // Close releases any resources held by the storage implementation. + Close() error +} + +// Item represents a file or directory in remote storage. +type Item struct { + Key string // The path/key of the item + ModifiedTime time.Time // Last modification time + Size int64 // Size in bytes (0 for directories) + IsDirectory bool // Whether this item is a directory +} + +// Config holds configuration options for creating storage instances. +type Config struct { + // S3 configuration + S3Region string + S3Bucket string + S3Profile string + S3KeyID string + S3Secret string + S3Endpoint string + S3ForcePath bool + S3DisableTLS bool + + // B2 configuration + B2KeyID string + B2AppKey string + B2Bucket string + B2ConcurrentConnections int + B2ForcePath bool + + // Google Cloud Storage configuration + GCSBucket string + GCSEndpoint string + GCSCredentialsFile string + + // Azure configuration + AzureAccount string + AzureKey string + AzureContainer string + AzureEndpoint string + + // SFTP configuration + SFTPHost string + SFTPPort string + SFTPUsername string + SFTPPassword string + SFTPDirectory string + SFTPIdentityFile string + SFTPIgnoreKnownHosts bool + + // FTP configuration + FTPHost string + FTPPort string + FTPUsername string + FTPPassword string + FTPDirectory string +} + +// StorageType represents the type of storage backend. +type StorageType string + +const ( + StorageTypeS3 StorageType = "s3" + StorageTypeGCS StorageType = "gcs" + StorageTypeAzure StorageType = "azure" + StorageTypeSFTP StorageType = "sftp" + StorageTypeFTP StorageType = "ftp" + StorageTypeLocal StorageType = "local" +) + +// NewStorage creates a new Storage instance based on the specified type and configuration. +func NewStorage(storageType StorageType, config Config) (Storage, error) { + switch storageType { + case StorageTypeS3: + return NewS3Storage(config) + case StorageTypeGCS: + return NewGCSStorage(config) + case StorageTypeAzure: + return NewAzureStorage(config) + case StorageTypeSFTP: + return NewSFTPStorage(config) + case StorageTypeFTP: + return NewFTPStorage(config) + case StorageTypeLocal: + return NewLocalStorage("/backups") + default: + return nil, fmt.Errorf("unsupported storage type: %s", storageType) + } +} + +// normalizePathSeparators converts OS-specific path separators to forward slashes. +// This is necessary for cloud storage services which expect forward slashes. +func normalizePathSeparators(path string) string { + if os.PathSeparator == '/' { + return path + } + return strings.ReplaceAll(path, string(os.PathSeparator), "/") +} + +// validateConfig checks if the required configuration fields are present. +func validateConfig(_ Config, requiredFields map[string]string) error { + var missing []string + for field, value := range requiredFields { + if value == "" { + missing = append(missing, field) + } + } + + if len(missing) > 0 { + return fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")) + } + + return nil +} diff --git a/storage_test.go b/storage_test.go new file mode 100644 index 0000000..59c9547 --- /dev/null +++ b/storage_test.go @@ -0,0 +1,41 @@ +package go_storage + +import ( + "context" + "testing" + "time" +) + +// Test helpers +func TestStorageInterface(t *testing.T) { + var _ Storage = (*S3Storage)(nil) + var _ Storage = (*GCSStorage)(nil) + var _ Storage = (*AzureStorage)(nil) + var _ Storage = (*SFTPStorage)(nil) +} + +func TestContextTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + config := Config{ + S3Region: "us-east-1", + S3Bucket: "test-bucket", + } + + s3, err := NewStorage(StorageTypeS3, config) + if err != nil { + t.Fatalf("Failed to create storage: %v", err) + } + defer func(s3 Storage) { + err = s3.Close() + if err != nil { + t.Logf("Failed to close storage: %v", err) + } + }(s3) + + err = s3.Upload(ctx, "test.txt", "remote-test.txt") + if err != nil { + t.Logf("Upload failed (expected in test): %v", err) + } +}