diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index f8ebe12..dcb6a6a 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -5,4 +5,5 @@ ubuntu workarounds rjack msgbox -xeact \ No newline at end of file +xeact +ABee diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index ab5243b..73606ea 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -140,6 +140,7 @@ headermap healthcheck healthz hec +Hetzner hmc homelab hostable @@ -237,7 +238,6 @@ pki podkova podman poststart -poxied prebaked privkey promauto @@ -250,7 +250,6 @@ pwuser qualys qwant qwantbot -QWEN rac rawler rcvar @@ -282,7 +281,6 @@ shirou Sidetrade simprint sitemap -Slackware sls Smartphone sni @@ -360,6 +358,7 @@ XOriginal XReal yae YAMLTo +Yda yeet yeetfile yourdomain diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index d963386..373dc87 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +- Add the [`s3api` storage backend](./admin/policies.mdx#s3api) to allow Anubis to use S3 API compatible object storage as its storage backend. + ## v1.22.0: Yda Hext > Someone has to make an effort at reconciliation if these conflicts are ever going to end. diff --git a/docs/docs/admin/policies.mdx b/docs/docs/admin/policies.mdx index 1646b0d..f95821d 100644 --- a/docs/docs/admin/policies.mdx +++ b/docs/docs/admin/policies.mdx @@ -196,6 +196,83 @@ store: path: /data/anubis.bdb ``` +### `s3api` + +A network-backed storage layer backed by [object storage](https://en.wikipedia.org/wiki/Object_storage), specifically using the [S3 API](https://docs.aws.amazon.com/AmazonS3/latest/API/Type_API_Reference.html). This can be backed by any S3-compatible object storage service such as: + +- [AWS S3](https://aws.amazon.com/s3/) +- [Cloudflare R2](https://www.cloudflare.com/developer-platform/products/r2/) +- [Hetzner Object Storage](https://www.hetzner.com/storage/object-storage/) +- [Minio](https://www.min.io/) +- [Tigris](https://www.tigrisdata.com/) + +If you are using a cloud platform, they likely provide an S3 compatible object storage service. If not, you may want to choose [one of the fastest options](https://www.tigrisdata.com/blog/benchmark-small-objects/). + +| Should I use this backend? | Yes/no | +| :------------------------------------------------------------ | :----- | +| Are you running only one instance of Anubis for this service? | 🚫 No | +| Does your service get a lot of traffic? | ✅ Yes | +| Do you want to store data persistently when Anubis restarts? | ✅ Yes | +| Do you run Anubis without mutable filesystem storage? | ✅ Yes | + +:::note + +Using this backend will cause a lot of S3 operations, at least one for creating challenges, one for invalidating challenges, one for updating challenges to prevent double-spends, and one for removing challenges. + +::: + +#### Configuration + +The `s3api` backend takes the following configuration options: + +| Name | Type | Example | Description | +| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `bucketName` | string | The name of the dedicated bucket for Anubis to store information in. | +| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. | + +:::note + +You should probably enable a lifecycle expiration rule for buckets containing Anubis data. Here is an example policy: + +```json +{ + "Rules": [ + { + "Status": "Enabled", + "Expiration": { + "Days": 7 + } + } + ] +} +``` + +Adjust this as facts and circumstances demand, but 7 days should be enough for anyone. + +::: + +Example: + +Assuming your environment looks like this: + +```sh +# All of the following are fake credentials that look like real ones. +AWS_ACCESS_KEY_ID=accordingToAllKnownRulesOfAviation +AWS_SECRET_ACCESS_KEY=thereIsNoWayABeeShouldBeAbleToFly +AWS_REGION=yow +AWS_ENDPOINT_URL_S3=https://yow.s3.probably-not-malware.lol +``` + +Then your configuration would look like this: + +```yaml +store: + backend: s3api + parameters: + bucketName: techaro-prod-anubis + pathStyle: false +``` + ### `valkey` [Valkey](https://valkey.io/) is an in-memory key/value store that clients access over the network. This allows multiple instances of Anubis to share information and does not require each instance of Anubis to have persistent filesystem storage. diff --git a/go.mod b/go.mod index eb75e8d..197042d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.24.2 require ( github.com/TecharoHQ/thoth-proto v0.4.0 github.com/a-h/templ v0.3.924 + github.com/aws/aws-sdk-go-v2 v1.38.3 + github.com/aws/aws-sdk-go-v2/config v1.31.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 github.com/cespare/xxhash/v2 v2.3.0 github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 github.com/gaissmai/bart v0.23.0 @@ -49,6 +52,21 @@ require ( github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect + github.com/aws/smithy-go v1.23.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect diff --git a/go.sum b/go.sum index bb2966d..3c789e2 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,42 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= +github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= +github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo= +github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= diff --git a/lib/store/all/all.go b/lib/store/all/all.go index 1da23db..74e66dd 100644 --- a/lib/store/all/all.go +++ b/lib/store/all/all.go @@ -6,5 +6,6 @@ package all import ( _ "github.com/TecharoHQ/anubis/lib/store/bbolt" _ "github.com/TecharoHQ/anubis/lib/store/memory" + _ "github.com/TecharoHQ/anubis/lib/store/s3api" _ "github.com/TecharoHQ/anubis/lib/store/valkey" ) diff --git a/lib/store/s3api/factory.go b/lib/store/s3api/factory.go new file mode 100644 index 0000000..34ac10b --- /dev/null +++ b/lib/store/s3api/factory.go @@ -0,0 +1,107 @@ +package s3api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/TecharoHQ/anubis/lib/store" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +var ( + ErrNoRegion = errors.New("s3api.Config: no region env var name defined") + ErrNoAccessKeyID = errors.New("s3api.Config: no access key id env var name defined") + ErrNoSecretAccessKey = errors.New("s3api.Config: no secret access key env var name defined") + ErrNoBucketName = errors.New("s3api.Config: no bucket name env var name defined") +) + +func init() { + store.Register("s3api", Factory{}) +} + +// S3API is the subset of the AWS S3 client used by this store. It enables mocking in tests. +type S3API interface { + PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) + DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) + HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) +} + +// Factory builds an S3-backed store. Tests can inject a Mock via Client. +// Factory can optionally carry a preconstructed S3 client (e.g., a mock in tests). +type Factory struct { + Client S3API +} + +func (f Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) { + var config Config + + if err := json.Unmarshal([]byte(data), &config); err != nil { + return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err) + } + + if err := config.Valid(); err != nil { + return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err) + } + + if config.BucketName == "" { + return nil, fmt.Errorf("%w: %s", store.ErrBadConfig, ErrNoBucketName) + } + + // If a client was injected (e.g., tests), use it directly. + if f.Client != nil { + return &Store{ + s3: f.Client, + bucket: config.BucketName, + }, nil + } + + cfg, err := awsConfig.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("can't load AWS config from environment: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = config.PathStyle + }) + + return &Store{ + s3: client, + bucket: config.BucketName, + }, nil +} + +func (Factory) Valid(data json.RawMessage) error { + var config Config + if err := json.Unmarshal([]byte(data), &config); err != nil { + return fmt.Errorf("%w: %w", store.ErrBadConfig, err) + } + + if err := config.Valid(); err != nil { + return fmt.Errorf("%w: %w", store.ErrBadConfig, err) + } + + return nil +} + +type Config struct { + PathStyle bool `json:"pathStyle"` + BucketName string `json:"bucketName"` +} + +func (c Config) Valid() error { + var errs []error + + if c.BucketName == "" { + errs = append(errs, ErrNoBucketName) + } + + if len(errs) != 0 { + return fmt.Errorf("s3api.Config: invalid config: %w", errors.Join(errs...)) + } + + return nil +} diff --git a/lib/store/s3api/s3api.go b/lib/store/s3api/s3api.go new file mode 100644 index 0000000..757a06f --- /dev/null +++ b/lib/store/s3api/s3api.go @@ -0,0 +1,78 @@ +package s3api + +import ( + "bytes" + "context" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/TecharoHQ/anubis/lib/store" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type Store struct { + s3 S3API + bucket string +} + +func (s *Store) Delete(ctx context.Context, key string) error { + normKey := strings.ReplaceAll(key, ":", "/") + // Emulate not found by probing first. + if _, err := s.s3.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil { + return fmt.Errorf("%w: %w", store.ErrNotFound, err) + } + if _, err := s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil { + return fmt.Errorf("can't delete from s3: %w", err) + } + return nil +} + +func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { + normKey := strings.ReplaceAll(key, ":", "/") + out, err := s.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &s.bucket, + Key: &normKey, + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err) + } + defer out.Body.Close() + if msStr, ok := out.Metadata["x-anubis-expiry-ms"]; ok && msStr != "" { + if ms, err := strconv.ParseInt(msStr, 10, 64); err == nil { + if time.Now().UnixMilli() >= ms { + _, _ = s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey}) + return nil, store.ErrNotFound + } + } + } + b, err := io.ReadAll(out.Body) + if err != nil { + return nil, fmt.Errorf("can't read s3 object: %w", err) + } + return b, nil +} + +func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error { + normKey := strings.ReplaceAll(key, ":", "/") + // S3 has no native TTL; we store object with metadata X-Anubis-Expiry as epoch seconds. + var meta map[string]string + if expiry > 0 { + exp := time.Now().Add(expiry).UnixMilli() + meta = map[string]string{"x-anubis-expiry-ms": fmt.Sprintf("%d", exp)} + } + _, err := s.s3.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &normKey, + Body: bytes.NewReader(value), + Metadata: meta, + }) + if err != nil { + return fmt.Errorf("can't put s3 object: %w", err) + } + return nil +} + +func (Store) IsPersistent() bool { return true } diff --git a/lib/store/s3api/s3api_test.go b/lib/store/s3api/s3api_test.go new file mode 100644 index 0000000..80309dc --- /dev/null +++ b/lib/store/s3api/s3api_test.go @@ -0,0 +1,140 @@ +package s3api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "sync" + "testing" + "time" + + "github.com/TecharoHQ/anubis/lib/store/storetest" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// mockS3 is an in-memory mock of the methods we use. +type mockS3 struct { + mu sync.RWMutex + bucket string + data map[string][]byte + meta map[string]map[string]string +} + +func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.data == nil { + m.data = map[string][]byte{} + } + if m.meta == nil { + m.meta = map[string]map[string]string{} + } + b, _ := io.ReadAll(in.Body) + m.data[aws.ToString(in.Key)] = bytes.Clone(b) + if in.Metadata != nil { + m.meta[aws.ToString(in.Key)] = map[string]string{} + for k, v := range in.Metadata { + m.meta[aws.ToString(in.Key)][k] = v + } + } + m.bucket = aws.ToString(in.Bucket) + return &s3.PutObjectOutput{}, nil +} + +func (m *mockS3) GetObject(ctx context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + m.mu.RLock() + defer m.mu.RUnlock() + b, ok := m.data[aws.ToString(in.Key)] + if !ok { + return nil, fmt.Errorf("not found") + } + out := &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(b))} + if md, ok := m.meta[aws.ToString(in.Key)]; ok { + out.Metadata = md + } + return out, nil +} + +func (m *mockS3) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, aws.ToString(in.Key)) + delete(m.meta, aws.ToString(in.Key)) + return &s3.DeleteObjectOutput{}, nil +} + +func (m *mockS3) HeadObject(ctx context.Context, in *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if _, ok := m.data[aws.ToString(in.Key)]; !ok { + return nil, fmt.Errorf("not found") + } + return &s3.HeadObjectOutput{}, nil +} + +func TestImpl(t *testing.T) { + mock := &mockS3{} + f := Factory{Client: mock} + + data, _ := json.Marshal(Config{ + BucketName: "bucket", + }) + + storetest.Common(t, f, json.RawMessage(data)) +} + +func TestKeyNormalization(t *testing.T) { + mock := &mockS3{} + f := Factory{Client: mock} + + data, _ := json.Marshal(Config{ + BucketName: "anubis", + }) + + s, err := f.Build(t.Context(), json.RawMessage(data)) + if err != nil { + t.Fatal(err) + } + + key := "a:b:c" + val := []byte("value") + if err := s.Set(t.Context(), key, val, 0); err != nil { + t.Fatalf("Set failed: %v", err) + } + // Ensure mock saw normalized key + mock.mu.RLock() + _, hasRaw := mock.data["a:b:c"] + got, hasNorm := mock.data["a/b/c"] + mock.mu.RUnlock() + if hasRaw { + t.Fatalf("mock contains raw key with colon; normalization failed") + } + if !hasNorm || !bytes.Equal(got, val) { + t.Fatalf("normalized key missing or wrong value: got=%q", string(got)) + } + + // Get using colon key should work + out, err := s.Get(t.Context(), key) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !bytes.Equal(out, val) { + t.Fatalf("Get returned wrong value: got=%q", string(out)) + } + + // Delete using colon key should delete normalized object + if err := s.Delete(t.Context(), key); err != nil { + t.Fatalf("Delete failed: %v", err) + } + // Give any async cleanup in tests a tick (not needed for mock, but harmless) + time.Sleep(1 * time.Millisecond) + mock.mu.RLock() + _, exists := mock.data["a/b/c"] + mock.mu.RUnlock() + if exists { + t.Fatalf("normalized key still exists after Delete") + } +} diff --git a/test/go.mod b/test/go.mod index 868f077..e61146d 100644 --- a/test/go.mod +++ b/test/go.mod @@ -5,7 +5,7 @@ go 1.24.5 replace github.com/TecharoHQ/anubis => .. require ( - github.com/TecharoHQ/anubis v1.21.3 + github.com/TecharoHQ/anubis v1.22.0 github.com/docker/docker v28.3.2+incompatible github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 github.com/google/uuid v1.6.0 @@ -18,6 +18,24 @@ require ( github.com/TecharoHQ/thoth-proto v0.4.0 // indirect github.com/a-h/templ v0.3.924 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect + github.com/aws/smithy-go v1.23.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect diff --git a/test/go.sum b/test/go.sum index 9877991..3711a85 100644 --- a/test/go.sum +++ b/test/go.sum @@ -16,6 +16,42 @@ github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k= github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= +github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= +github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo= +github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=