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) + } +}