Add unmounted captured AWS namespace candidate and signup binding
8 files changed,
+1235
−0
internal/console/signupscope_test.go+8 −0internal/console/signupscopebinding.go+29 −0internal/console/signupscopebinding_test.go+198 −0internal/release/namespacecandidate.go+197 −0internal/release/namespacecandidate_test.go+569 −0internal/release/namespacecredentials.go+19 −0internal/s3compat/awsprofile.go+114 −0internal/s3compat/awsprofile_test.go+101 −0
CI passed — run details
diff --git a/internal/console/signupscope_test.go b/internal/console/signupscope_test.go| index 588dd96d7..5d0c65bee 100644 |
| --- a/internal/console/signupscope_test.go |
| +++ b/internal/console/signupscope_test.go |
| @@ -328,6 +328,11 @@ func TestSignupScopeBucketRefusals(t *testing.T) { |
| if _, code := signupScopeEncode(d); code == signupScopeNoRefusal { |
| t.Fatalf("bucket %q encoded", bucket) |
| } |
| + for _, style := range []string{"path", "virtual"} { |
| + if _, code := signupBindingTestCandidate(t, bucket, "eu-west-1", "aws", style); code == 0 { |
| + t.Fatalf("codec-refused bucket %q accepted by factory in %s mode", bucket, style) |
| + } |
| + } |
| }) |
| } |
| } |
| @@ -580,6 +585,9 @@ func TestSignupScopeCodecStaysPureAndUnmounted(t *testing.T) { |
| t.Fatal(err) |
| } |
| ast.Inspect(file, func(node ast.Node) bool { |
| + if decl, ok := node.(*ast.FuncDecl); ok && name == "signupscopebinding.go" && decl.Name.Name == "signupScopeBindingFromCandidate" { |
| + return false |
| + } |
| ident, ok := node.(*ast.Ident) |
| if ok && codecNames[ident.Name] { |
| t.Errorf("%s: production reference to %s; codec mounting needs a separate reviewed design", fset.Position(ident.Pos()), ident.Name) |
diff --git a/internal/console/signupscopebinding.go b/internal/console/signupscopebinding.go| new file mode 100644 |
| index 000000000..24c86e76e |
| --- /dev/null |
| +++ b/internal/console/signupscopebinding.go |
| @@ -0,0 +1,29 @@ |
| +package console |
| + |
| +import "github.com/carlosframework/platform/internal/release" |
| + |
| +type signupScopeBinding struct { |
| + candidate release.NamespaceCandidate |
| + encoded string |
| +} |
| + |
| +func (b signupScopeBinding) bytes() []byte { return []byte(b.encoded) } |
| + |
| +func signupScopeBindingFromCandidate(candidate release.NamespaceCandidate, authorityID, lineageID string) (signupScopeBinding, signupScopeRefusal) { |
| + facts, code := candidate.NamespaceFacts() |
| + if code != release.NamespaceAccepted || facts.Mode != "aws" || facts.EffectivePrefix != "" { |
| + return signupScopeBinding{}, signupScopeRefuseField |
| + } |
| + encoded, refusal := signupScopeEncode(signupScopeDescriptor{ |
| + backendKind: facts.BackendKind, mode: signupScopeModeAWS, |
| + logicalAuthorityID: authorityID, databaseLineageID: lineageID, |
| + partition: facts.Partition, region: facts.Region, customOrigin: facts.CustomOrigin, |
| + namespaceKind: facts.NamespaceKind, namespaceToken: facts.NamespaceToken, |
| + addressingStyle: facts.AddressingStyle, bucket: facts.Bucket, effectivePrefix: facts.EffectivePrefix, |
| + adapterConfigurationRevision: facts.AdapterConfigurationRevision, |
| + }) |
| + if refusal != signupScopeNoRefusal { |
| + return signupScopeBinding{}, refusal |
| + } |
| + return signupScopeBinding{candidate: candidate, encoded: string(encoded)}, signupScopeNoRefusal |
| +} |
diff --git a/internal/console/signupscopebinding_test.go b/internal/console/signupscopebinding_test.go| new file mode 100644 |
| index 000000000..949e2c052 |
| --- /dev/null |
| +++ b/internal/console/signupscopebinding_test.go |
| @@ -0,0 +1,198 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/s3compat" |
| +) |
| + |
| +func signupBindingTestCandidate(t *testing.T, bucket, region, partition, style string) (release.NamespaceCandidate, release.NamespaceRefusal) { |
| + t.Helper() |
| + values := map[string]string{"CARLOS_DEPLOYMENT_BUCKET": bucket, "AWS_REGION": region, "CARLOS_S3_PARTITION": partition, "CARLOS_S3_ADDRESSING_STYLE": style} |
| + snapshot, code := s3compat.CaptureAWSProfile(func(k string) (string, bool) { v, ok := values[k]; return v, ok }) |
| + if code != s3compat.AWSProfileAccepted { |
| + t.Fatalf("capture %d", code) |
| + } |
| + return release.NewNamespaceCandidate(snapshot) |
| +} |
| + |
| +func TestSignupScopeBindingPairsCandidateAndCopies(t *testing.T) { |
| + authority, lineage := strings.Repeat("1", 64), strings.Repeat("2", 64) |
| + var previous []byte |
| + for _, tc := range []struct{ bucket, region, partition, style string }{ |
| + {"factory-test-bucket", "eu-west-1", "aws", "path"}, |
| + {"second-test-bucket", "eu-west-1", "aws", "path"}, |
| + {"second-test-bucket", "us-east-1", "aws", "virtual"}, |
| + {"second-test-bucket", "cn-north-1", "aws-cn", "path"}, |
| + } { |
| + candidate, code := signupBindingTestCandidate(t, tc.bucket, tc.region, tc.partition, tc.style) |
| + if code != release.NamespaceAccepted { |
| + t.Fatalf("candidate %d", code) |
| + } |
| + binding, refusal := signupScopeBindingFromCandidate(candidate, authority, lineage) |
| + if refusal != signupScopeNoRefusal { |
| + t.Fatalf("binding %d", refusal) |
| + } |
| + encoded := binding.bytes() |
| + saved := bytes.Clone(encoded) |
| + d, refusal := signupScopeDecode(encoded) |
| + if refusal != signupScopeNoRefusal { |
| + t.Fatalf("decode %d", refusal) |
| + } |
| + if d.bucket != tc.bucket || d.region != tc.region || d.partition != tc.partition || d.addressingStyle != tc.style || d.logicalAuthorityID != authority || d.databaseLineageID != lineage || d.effectivePrefix != "" || d.mode != signupScopeModeAWS || d.adapterConfigurationRevision != "aws-root-v1-sdk-s3-1-105-0" { |
| + t.Fatalf("paired descriptor %#v", d) |
| + } |
| + paired, _ := binding.candidate.NamespaceFacts() |
| + input, _ := candidate.NamespaceFacts() |
| + if paired != input { |
| + t.Fatal("different candidate retained") |
| + } |
| + input.Bucket = "mutated" |
| + candidate = release.NamespaceCandidate{} |
| + for i := range encoded { |
| + encoded[i] ^= 0xff |
| + } |
| + if !bytes.Equal(binding.bytes(), saved) { |
| + t.Fatal("returned bytes alias binding") |
| + } |
| + if previous != nil && bytes.Equal(previous, saved) { |
| + t.Fatal("changed candidate used prior descriptor") |
| + } |
| + previous = saved |
| + } |
| + candidate, _ := signupBindingTestCandidate(t, "factory-test-bucket", "eu-west-1", "aws", "path") |
| + first, code := signupScopeBindingFromCandidate(candidate, authority, lineage) |
| + if code != signupScopeNoRefusal { |
| + t.Fatal(code) |
| + } |
| + for _, ids := range [][2]string{{lineage, lineage}, {authority, authority}} { |
| + b, code := signupScopeBindingFromCandidate(candidate, ids[0], ids[1]) |
| + if code != signupScopeNoRefusal || bytes.Equal(first.bytes(), b.bytes()) { |
| + t.Fatal("identity change lost") |
| + } |
| + } |
| + for _, ids := range [][2]string{{"", lineage}, {authority, ""}, {strings.Repeat("0", 64), lineage}, {authority, "invalid"}} { |
| + b, code := signupScopeBindingFromCandidate(candidate, ids[0], ids[1]) |
| + if code == signupScopeNoRefusal || len(b.bytes()) != 0 { |
| + t.Fatal("invalid IDs accepted") |
| + } |
| + } |
| + if b, code := signupScopeBindingFromCandidate(release.NamespaceCandidate{}, authority, lineage); code == signupScopeNoRefusal || len(b.bytes()) != 0 { |
| + t.Fatal("zero candidate bound") |
| + } |
| +} |
| + |
| +func TestSignupScopeBindingStaysPrivateAndUnmounted(t *testing.T) { |
| + fset := token.NewFileSet() |
| + files, err := filepath.Glob("*.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range files { |
| + if strings.HasSuffix(name, "_test.go") { |
| + continue |
| + } |
| + file, err := parser.ParseFile(fset, name, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, decl := range file.Decls { |
| + allowed := false |
| + if name == "signupscopebinding.go" { |
| + switch d := decl.(type) { |
| + case *ast.FuncDecl: |
| + allowed = d.Name.Name == "signupScopeBindingFromCandidate" || d.Name.Name == "bytes" && d.Recv != nil && len(d.Recv.List) == 1 |
| + case *ast.GenDecl: |
| + if len(d.Specs) == 1 { |
| + if s, ok := d.Specs[0].(*ast.TypeSpec); ok { |
| + allowed = s.Name.Name == "signupScopeBinding" |
| + } |
| + } |
| + } |
| + } |
| + if allowed { |
| + continue |
| + } |
| + ast.Inspect(decl, func(n ast.Node) bool { |
| + if id, ok := n.(*ast.Ident); ok && (id.Name == "signupScopeBinding" || id.Name == "signupScopeBindingFromCandidate") { |
| + t.Errorf("binding production reference %s", fset.Position(id.Pos())) |
| + } |
| + return true |
| + }) |
| + } |
| + } |
| + typ := reflect.TypeOf(signupScopeBinding{}) |
| + for i := 0; i < typ.NumField(); i++ { |
| + if typ.Field(i).IsExported() || typ.Field(i).Anonymous { |
| + t.Fatal("binding exported field") |
| + } |
| + } |
| + ctor := reflect.TypeOf(signupScopeBindingFromCandidate) |
| + if ctor.NumIn() != 3 || ctor.In(0) != reflect.TypeOf(release.NamespaceCandidate{}) || ctor.In(1).Kind() != reflect.String || ctor.In(2).Kind() != reflect.String || ctor.IsVariadic() { |
| + t.Fatal("binding accepts claimed facts/store") |
| + } |
| +} |
| + |
| +func TestSignupScopeBindingRejectsFutureProfileAST(t *testing.T) { |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, "signupscopebinding.go", nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var helper *ast.FuncDecl |
| + for _, decl := range file.Decls { |
| + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "signupScopeBindingFromCandidate" { |
| + helper = fn |
| + } |
| + } |
| + if helper == nil || len(helper.Body.List) < 2 { |
| + t.Fatal("missing helper guard") |
| + } |
| + guard, ok := helper.Body.List[1].(*ast.IfStmt) |
| + if !ok { |
| + t.Fatal("guard must precede encoding") |
| + } |
| + checks := map[string]bool{} |
| + ast.Inspect(guard.Cond, func(n ast.Node) bool { |
| + binary, ok := n.(*ast.BinaryExpr) |
| + if !ok || binary.Op != token.NEQ { |
| + return true |
| + } |
| + sel, ok := binary.X.(*ast.SelectorExpr) |
| + if !ok { |
| + return true |
| + } |
| + id, ok := sel.X.(*ast.Ident) |
| + if !ok || id.Name != "facts" { |
| + return true |
| + } |
| + literal, ok := binary.Y.(*ast.BasicLit) |
| + if !ok { |
| + return true |
| + } |
| + checks[sel.Sel.Name+"="+literal.Value] = true |
| + return true |
| + }) |
| + if !checks[`Mode="aws"`] || !checks[`EffectivePrefix=""`] { |
| + t.Fatal("future custom/prefix candidate needs independent refusal") |
| + } |
| + if len(guard.Body.List) != 1 { |
| + t.Fatal("profile refusal side effects") |
| + } |
| + ret, ok := guard.Body.List[0].(*ast.ReturnStmt) |
| + if !ok || len(ret.Results) != 2 { |
| + t.Fatal("profile guard does not refuse") |
| + } |
| + code, ok := ret.Results[1].(*ast.Ident) |
| + if !ok || code.Name != "signupScopeRefuseField" { |
| + t.Fatal("profile guard grants binding") |
| + } |
| +} |
diff --git a/internal/release/namespacecandidate.go b/internal/release/namespacecandidate.go| new file mode 100644 |
| index 000000000..0d87b3425 |
| --- /dev/null |
| +++ b/internal/release/namespacecandidate.go |
| @@ -0,0 +1,197 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "net" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/aws/retry" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + smithyauth "github.com/aws/smithy-go/auth" |
| + endpoints "github.com/aws/smithy-go/endpoints" |
| + "github.com/aws/smithy-go/middleware" |
| + smithyhttp "github.com/aws/smithy-go/transport/http" |
| + "github.com/carlosframework/platform/internal/s3compat" |
| + "github.com/carlosframework/platform/internal/sigfence" |
| +) |
| + |
| +type NamespaceRefusal uint8 |
| + |
| +const ( |
| + NamespaceAccepted NamespaceRefusal = iota |
| + NamespaceRefuseSnapshot |
| + NamespaceRefuseBucket |
| + NamespaceRefuseCandidate |
| +) |
| + |
| +type NamespaceFacts struct { |
| + BackendKind string |
| + Mode string |
| + Partition string |
| + Region string |
| + CustomOrigin string |
| + NamespaceKind string |
| + NamespaceToken string |
| + AddressingStyle string |
| + Bucket string |
| + EffectivePrefix string |
| + AdapterConfigurationRevision string |
| +} |
| + |
| +type NamespaceCandidate struct { |
| + adapter *S3Store |
| + facts NamespaceFacts |
| +} |
| + |
| +func (c NamespaceCandidate) NamespaceFacts() (NamespaceFacts, NamespaceRefusal) { |
| + if c.adapter == nil { |
| + return NamespaceFacts{}, NamespaceRefuseCandidate |
| + } |
| + return c.facts, NamespaceAccepted |
| +} |
| + |
| +func NewNamespaceCandidate(snapshot s3compat.AWSProfileSnapshot) (NamespaceCandidate, NamespaceRefusal) { |
| + values, code := snapshot.Values() |
| + if code != s3compat.AWSProfileAccepted { |
| + return NamespaceCandidate{}, NamespaceRefuseSnapshot |
| + } |
| + if !namespaceBucket(values.Bucket) || (values.AddressingStyle == "virtual" && strings.Contains(values.Bucket, ".")) { |
| + return NamespaceCandidate{}, NamespaceRefuseBucket |
| + } |
| + facts := NamespaceFacts{ |
| + BackendKind: "s3", Mode: "aws", Partition: values.Partition, Region: values.Region, |
| + NamespaceKind: "global", AddressingStyle: values.AddressingStyle, Bucket: values.Bucket, |
| + AdapterConfigurationRevision: values.Revision, |
| + } |
| + client := awss3.New(namespaceOptions(values)) |
| + return NamespaceCandidate{adapter: &S3Store{Client: client, Bucket: facts.Bucket}, facts: facts}, NamespaceAccepted |
| +} |
| + |
| +func namespaceOptions(v s3compat.AWSProfileValues) awss3.Options { |
| + return awss3.Options{ |
| + Credentials: namespaceRefusingCredentials{}, |
| + Region: v.Region, |
| + EndpointResolverV2: namespaceResolver{values: v}, |
| + HTTPClient: &http.Client{ |
| + Transport: &http.Transport{ |
| + Proxy: nil, |
| + DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, |
| + ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, |
| + TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: time.Second, |
| + }, |
| + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| + Jar: nil, |
| + }, |
| + APIOptions: []func(*middleware.Stack) error{sigfence.WatchAttempts}, |
| + HTTPSignerV4: sigfence.NewS3Signer(), |
| + RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, |
| + ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, |
| + ClientLogMode: 0, |
| + DefaultsMode: aws.DefaultsModeStandard, |
| + RetryMode: aws.RetryModeStandard, |
| + RetryMaxAttempts: 3, |
| + Retryer: retry.NewStandard(func(o *retry.StandardOptions) { o.MaxAttempts = 3 }), |
| + UsePathStyle: v.AddressingStyle == "path", |
| + UseAccelerate: false, UseARNRegion: false, UseDualstack: false, |
| + EndpointOptions: awss3.EndpointResolverOptions{ |
| + UseFIPSEndpoint: aws.FIPSEndpointStateDisabled, |
| + UseDualStackEndpoint: aws.DualStackEndpointStateDisabled, |
| + }, |
| + DisableMultiRegionAccessPoints: true, |
| + DisableS3ExpressSessionAuth: aws.Bool(true), |
| + } |
| +} |
| + |
| +type namespaceEndpointRefusal uint8 |
| + |
| +func (e namespaceEndpointRefusal) Error() string { return strconv.Itoa(int(e)) } |
| + |
| +type namespaceResolver struct{ values s3compat.AWSProfileValues } |
| + |
| +func (r namespaceResolver) ResolveEndpoint(ctx context.Context, p awss3.EndpointParameters) (endpoints.Endpoint, error) { |
| + v := r.values |
| + if p.Region == nil || *p.Region != v.Region || p.Bucket == nil || *p.Bucket != v.Bucket || |
| + p.Endpoint != nil || aws.ToBool(p.UseFIPS) || aws.ToBool(p.UseDualStack) || aws.ToBool(p.Accelerate) || |
| + aws.ToBool(p.UseGlobalEndpoint) || aws.ToBool(p.UseObjectLambdaEndpoint) || aws.ToBool(p.UseArnRegion) || |
| + aws.ToBool(p.UseS3ExpressControlEndpoint) || !aws.ToBool(p.DisableMultiRegionAccessPoints) || |
| + !aws.ToBool(p.DisableS3ExpressSessionAuth) || p.ForcePathStyle == nil || *p.ForcePathStyle != (v.AddressingStyle == "path") { |
| + return endpoints.Endpoint{}, namespaceEndpointRefusal(1) |
| + } |
| + ep, err := awss3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, p) |
| + if err != nil { |
| + return endpoints.Endpoint{}, err |
| + } |
| + if err := r.checkEndpoint(ep); err != nil { |
| + return endpoints.Endpoint{}, err |
| + } |
| + return ep, nil |
| +} |
| + |
| +func (r namespaceResolver) checkEndpoint(ep endpoints.Endpoint) error { |
| + v, u := r.values, ep.URI |
| + if u.Scheme != "https" || u.Host == "" || u.User != nil || u.Opaque != "" || u.Port() != "" || |
| + u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawPath != "" || |
| + !(u.Host == v.DNSSuffix || strings.HasSuffix(u.Host, "."+v.DNSSuffix)) { |
| + return namespaceEndpointRefusal(2) |
| + } |
| + switch v.AddressingStyle { |
| + case "virtual": |
| + if !strings.HasPrefix(u.Host, v.Bucket+".") || (u.Path != "" && u.Path != "/") { |
| + return namespaceEndpointRefusal(3) |
| + } |
| + case "path": |
| + if u.Path != "/"+v.Bucket && u.Path != "/"+v.Bucket+"/" { |
| + return namespaceEndpointRefusal(3) |
| + } |
| + default: |
| + return namespaceEndpointRefusal(3) |
| + } |
| + if len(ep.Headers) != 0 || ep.Properties.Has("backend") { |
| + return namespaceEndpointRefusal(4) |
| + } |
| + options, ok := smithyauth.GetAuthOptions(&ep.Properties) |
| + if !ok || len(options) != 1 || options[0] == nil || options[0].SchemeID != "sigv4" { |
| + return namespaceEndpointRefusal(5) |
| + } |
| + name, okName := smithyhttp.GetSigV4SigningName(&options[0].SignerProperties) |
| + region, okRegion := smithyhttp.GetSigV4SigningRegion(&options[0].SignerProperties) |
| + if !okName || !okRegion || name != "s3" || region != v.Region { |
| + return namespaceEndpointRefusal(6) |
| + } |
| + return nil |
| +} |
| + |
| +func namespaceBucket(s string) bool { |
| + alnum := func(c byte) bool { return c >= 'a' && c <= 'z' || c >= '0' && c <= '9' } |
| + if len(s) < 3 || len(s) > 63 || !alnum(s[0]) || !alnum(s[len(s)-1]) { |
| + return false |
| + } |
| + for i := range len(s) { |
| + if !alnum(s[i]) && s[i] != '.' && s[i] != '-' { |
| + return false |
| + } |
| + } |
| + if strings.Contains(s, "..") || strings.HasPrefix(s, "xn--") || strings.HasPrefix(s, "sthree-") || |
| + strings.HasSuffix(s, "-s3alias") || strings.HasSuffix(s, "--ol-s3") || strings.HasSuffix(s, ".mrap") || strings.HasSuffix(s, "--x-s3") { |
| + return false |
| + } |
| + parts := strings.Split(s, ".") |
| + if len(parts) == 4 { |
| + decimal := true |
| + for _, part := range parts { |
| + for i := range len(part) { |
| + if part[i] < '0' || part[i] > '9' { |
| + decimal = false |
| + } |
| + } |
| + } |
| + if decimal { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/release/namespacecandidate_test.go b/internal/release/namespacecandidate_test.go| new file mode 100644 |
| index 000000000..18b40db9b |
| --- /dev/null |
| +++ b/internal/release/namespacecandidate_test.go |
| @@ -0,0 +1,569 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + smithyauth "github.com/aws/smithy-go/auth" |
| + endpoints "github.com/aws/smithy-go/endpoints" |
| + smithyhttp "github.com/aws/smithy-go/transport/http" |
| + "github.com/carlosframework/platform/internal/s3compat" |
| + "github.com/carlosframework/platform/internal/sigfence" |
| +) |
| + |
| +func namespaceTestSnapshot(t *testing.T, bucket, region, partition, style string) s3compat.AWSProfileSnapshot { |
| + t.Helper() |
| + values := map[string]string{"CARLOS_DEPLOYMENT_BUCKET": bucket, "AWS_REGION": region, "CARLOS_S3_PARTITION": partition, "CARLOS_S3_ADDRESSING_STYLE": style} |
| + snapshot, code := s3compat.CaptureAWSProfile(func(k string) (string, bool) { v, ok := values[k]; return v, ok }) |
| + if code != s3compat.AWSProfileAccepted { |
| + t.Fatalf("capture: %d", code) |
| + } |
| + return snapshot |
| +} |
| + |
| +func namespaceTestCandidate(t *testing.T, bucket, region, partition, style string) NamespaceCandidate { |
| + t.Helper() |
| + c, code := NewNamespaceCandidate(namespaceTestSnapshot(t, bucket, region, partition, style)) |
| + if code != NamespaceAccepted { |
| + t.Fatalf("construct: %d", code) |
| + } |
| + return c |
| +} |
| + |
| +type namespaceTestTransport func(*http.Request) (*http.Response, error) |
| + |
| +func (f namespaceTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| +func namespaceTestResponse(r *http.Request, status int) *http.Response { |
| + return &http.Response{StatusCode: status, Header: http.Header{"Etag": []string{`"test-etag"`}}, Body: io.NopCloser(strings.NewReader("")), Request: r} |
| +} |
| + |
| +func TestNamespaceCandidateNilCredentialsAnonymousWitness(t *testing.T) { |
| + snapshot := namespaceTestSnapshot(t, "factory-test-bucket", "eu-west-1", "aws", "path") |
| + v, _ := snapshot.Values() |
| + opts := namespaceOptions(v) |
| + opts.Credentials = nil |
| + calls := 0 |
| + opts.HTTPClient.(*http.Client).Transport = namespaceTestTransport(func(r *http.Request) (*http.Response, error) { |
| + calls++ |
| + if r.Header.Get("Authorization") != "" { |
| + t.Error("nil credentials unexpectedly signed") |
| + } |
| + return namespaceTestResponse(r, 200), nil |
| + }) |
| + store := &S3Store{Client: awss3.New(opts), Bucket: v.Bucket} |
| + if _, err := store.Put(context.Background(), "test-key", []byte("test"), ""); err != nil || calls != 1 { |
| + t.Fatalf("pinned SDK nil witness changed: err=%v dispatches=%d", err, calls) |
| + } |
| +} |
| + |
| +type namespaceTestRefusalCounter struct{ calls *int } |
| + |
| +func (p namespaceTestRefusalCounter) Retrieve(ctx context.Context) (aws.Credentials, error) { |
| + *p.calls++ |
| + return (namespaceRefusingCredentials{}).Retrieve(ctx) |
| +} |
| + |
| +type namespaceTestSigner struct{ calls *int } |
| + |
| +func (s namespaceTestSigner) SignHTTP(context.Context, aws.Credentials, *http.Request, string, string, string, time.Time, ...func(*v4.SignerOptions)) error { |
| + *s.calls++ |
| + return errors.New("unexpected signing") |
| +} |
| + |
| +func TestNamespaceCandidateRefusingCredentialsZeroDispatch(t *testing.T) { |
| + for _, key := range []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_EC2_METADATA_SERVICE_ENDPOINT"} { |
| + t.Setenv(key, "test-only-never-read") |
| + } |
| + c := namespaceTestCandidate(t, "factory-test-bucket", "eu-west-1", "aws", "path") |
| + calls := 0 |
| + transport := namespaceTestTransport(func(r *http.Request) (*http.Response, error) { |
| + calls++ |
| + t.Errorf("unexpected request headers: %v", r.Header) |
| + return namespaceTestResponse(r, 200), nil |
| + }) |
| + c.adapter.Client.Options().HTTPClient.(*http.Client).Transport = transport |
| + if _, err := c.adapter.Put(context.Background(), "test-key", []byte("test"), ""); !errors.Is(err, namespaceCredentialsUnavailable) || calls != 0 { |
| + t.Fatalf("candidate refusal err=%v dispatches=%d", err, calls) |
| + } |
| + retrieves, signs := 0, 0 |
| + opts := c.adapter.Client.Options() |
| + opts.Credentials = namespaceTestRefusalCounter{&retrieves} |
| + opts.HTTPSignerV4 = namespaceTestSigner{&signs} |
| + store := &S3Store{Client: awss3.New(opts), Bucket: c.adapter.Bucket} |
| + for _, operation := range []func() error{ |
| + func() error { _, e := store.Put(context.Background(), "key", []byte("test"), ""); return e }, |
| + func() error { _, _, e := store.Get(context.Background(), "key"); return e }, |
| + func() error { _, e := store.List(context.Background(), ""); return e }, |
| + func() error { return store.Delete(context.Background(), "key") }, |
| + } { |
| + retrieves = 0 |
| + if err := operation(); !errors.Is(err, namespaceCredentialsUnavailable) || calls != 0 || signs != 0 || retrieves != 1 { |
| + t.Fatalf("refusal err=%v dispatch=%d sign=%d retrieve=%d", err, calls, signs, retrieves) |
| + } |
| + } |
| + provider := namespaceRefusingCredentials{} |
| + if aws.IsCredentialsProvider(provider, (*aws.AnonymousCredentials)(nil)) { |
| + t.Fatal("refusing provider treated as anonymous") |
| + } |
| + value, err := provider.Retrieve(context.Background()) |
| + if value != (aws.Credentials{}) || err != namespaceCredentialsUnavailable { |
| + t.Fatal("provider produced material or variable error") |
| + } |
| + typ := reflect.TypeOf(provider) |
| + if typ.NumField() != 0 || typ.NumMethod() != 1 || typ.Method(0).Name != "Retrieve" { |
| + t.Fatal("provider has state or another interface") |
| + } |
| + errType := reflect.TypeOf(namespaceCredentialsUnavailable) |
| + if errType.NumMethod() != 1 || errType.Method(0).Name != "Error" { |
| + t.Fatal("refusal acquired retry/source interface") |
| + } |
| +} |
| + |
| +func namespaceTestCredentialClient(c NamespaceCandidate, transport http.RoundTripper) *S3Store { |
| + opts := c.adapter.Client.Options() |
| + opts.Credentials = credentials.NewStaticCredentialsProvider("TESTKEY", "TESTSECRET", "") |
| + original := opts.HTTPClient.(*http.Client) |
| + copied := *original |
| + copied.Transport = transport |
| + opts.HTTPClient = &copied |
| + return &S3Store{Client: awss3.New(opts), Bucket: c.adapter.Bucket} |
| +} |
| + |
| +func namespaceTestParams(v s3compat.AWSProfileValues) awss3.EndpointParameters { |
| + return awss3.EndpointParameters{Region: aws.String(v.Region), Bucket: aws.String(v.Bucket), UseFIPS: aws.Bool(false), UseDualStack: aws.Bool(false), |
| + ForcePathStyle: aws.Bool(v.AddressingStyle == "path"), Accelerate: aws.Bool(false), UseGlobalEndpoint: aws.Bool(false), UseArnRegion: aws.Bool(false), |
| + DisableMultiRegionAccessPoints: aws.Bool(true), DisableS3ExpressSessionAuth: aws.Bool(true)} |
| +} |
| + |
| +func TestNamespaceCandidateOwnedOptionsAndSurface(t *testing.T) { |
| + snapshot := namespaceTestSnapshot(t, "factory-test-bucket", "eu-west-1", "aws", "path") |
| + values, _ := snapshot.Values() |
| + o := namespaceOptions(values) |
| + if o.BaseEndpoint != nil || o.EndpointResolver != nil || reflect.TypeOf(o.Credentials) != reflect.TypeOf(namespaceRefusingCredentials{}) || o.AuthSchemeResolver != nil || o.AuthSchemes != nil || o.AuthSchemePreference != nil || |
| + o.ExpressCredentials != nil || !reflect.DeepEqual(o.Interceptors, smithyhttp.InterceptorRegistry{}) || len(o.APIOptions) != 1 || |
| + reflect.ValueOf(o.APIOptions[0]).Pointer() != reflect.ValueOf(sigfence.WatchAttempts).Pointer() { |
| + t.Fatal("unowned callback or credential") |
| + } |
| + if _, ok := o.HTTPSignerV4.(sigfence.Signer); !ok { |
| + t.Fatal("signer changed") |
| + } |
| + if o.DefaultsMode != aws.DefaultsModeStandard || o.RetryMode != aws.RetryModeStandard || o.RetryMaxAttempts != 3 || o.Retryer.MaxAttempts() != 3 || |
| + o.RequestChecksumCalculation != aws.RequestChecksumCalculationWhenRequired || o.ResponseChecksumValidation != aws.ResponseChecksumValidationWhenRequired || |
| + o.ClientLogMode != 0 || o.UseAccelerate || o.UseARNRegion || o.UseDualstack || !o.UsePathStyle || !o.DisableMultiRegionAccessPoints || |
| + !aws.ToBool(o.DisableS3ExpressSessionAuth) || o.EndpointOptions.UseFIPSEndpoint != aws.FIPSEndpointStateDisabled || |
| + o.EndpointOptions.UseDualStackEndpoint != aws.DualStackEndpointStateDisabled { |
| + t.Fatalf("profile options: %#v", o) |
| + } |
| + h := o.HTTPClient.(*http.Client) |
| + tr := h.Transport.(*http.Transport) |
| + if h.Jar != nil || h.CheckRedirect == nil || h.CheckRedirect(nil, nil) != http.ErrUseLastResponse || tr.Proxy != nil || |
| + tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify { |
| + t.Fatal("transport escape") |
| + } |
| + second := namespaceOptions(values) |
| + if second.HTTPClient == o.HTTPClient || second.Retryer == o.Retryer || second.HTTPClient.(*http.Client).Transport == tr { |
| + t.Fatal("shared mutable owned state") |
| + } |
| + c := namespaceTestCandidate(t, values.Bucket, values.Region, values.Partition, values.AddressingStyle) |
| + before, _ := c.NamespaceFacts() |
| + copy, _ := c.NamespaceFacts() |
| + rv := reflect.ValueOf(©).Elem() |
| + for i := 0; i < rv.NumField(); i++ { |
| + rv.Field(i).SetString("changed") |
| + } |
| + after, _ := c.NamespaceFacts() |
| + if before != after { |
| + t.Fatal("facts alias") |
| + } |
| + values.Bucket = "changed" |
| + snapshot = s3compat.AWSProfileSnapshot{} |
| + if c.adapter.Bucket != before.Bucket { |
| + t.Fatal("caller mutation affected adapter") |
| + } |
| + for _, typ := range []reflect.Type{reflect.TypeOf(c), reflect.TypeOf(&c)} { |
| + if typ.NumMethod() != 1 || typ.Method(0).Name != "NamespaceFacts" { |
| + t.Fatalf("candidate methods: %v", typ) |
| + } |
| + if typ.Implements(reflect.TypeOf((*Store)(nil)).Elem()) { |
| + t.Fatal("candidate exposes store") |
| + } |
| + } |
| + typ := reflect.TypeOf(c) |
| + for i := 0; i < typ.NumField(); i++ { |
| + if typ.Field(i).IsExported() || typ.Field(i).Anonymous { |
| + t.Fatal("candidate field escape") |
| + } |
| + } |
| + ctor := reflect.TypeOf(NewNamespaceCandidate) |
| + if ctor.NumIn() != 1 || ctor.In(0) != reflect.TypeOf(s3compat.AWSProfileSnapshot{}) || ctor.IsVariadic() { |
| + t.Fatal("constructor accepts unsupported wrapper/options") |
| + } |
| + if _, code := NewNamespaceCandidate(snapshot); code == NamespaceAccepted { |
| + t.Fatal("zero snapshot accepted") |
| + } |
| + if _, code := (NamespaceCandidate{}).NamespaceFacts(); code == NamespaceAccepted { |
| + t.Fatal("zero candidate accepted") |
| + } |
| +} |
| + |
| +func TestNamespaceCandidateRequestShapeAndFence(t *testing.T) { |
| + for _, style := range []string{"path", "virtual"} { |
| + t.Run(style, func(t *testing.T) { |
| + snapshot := namespaceTestSnapshot(t, "factory-test-bucket", "eu-west-1", "aws", style) |
| + for _, key := range []string{"CARLOS_DEPLOYMENT_BUCKET", "AWS_REGION", "CARLOS_S3_PARTITION", "CARLOS_S3_ADDRESSING_STYLE", "CARLOS_S3_ENDPOINT", "CARLOS_S3_QUIRKS", "AWS_ENDPOINT_URL", "AWS_ENDPOINT_URL_S3", "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "AWS_USE_FIPS_ENDPOINT", "AWS_USE_DUALSTACK_ENDPOINT", "AWS_S3_USE_ARN_REGION", "AWS_CA_BUNDLE", "AWS_AUTH_SCHEME_PREFERENCE", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "AWS_LAMBDA_FUNCTION_NAME", "_X_AMZN_TRACE_ID", "AWS_EXECUTION_ENV", "AWS_DEFAULT_REGION", "AWS_PROFILE", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"} { |
| + t.Setenv(key, "changed-after-capture") |
| + } |
| + c, code := NewNamespaceCandidate(snapshot) |
| + if code != NamespaceAccepted { |
| + t.Fatalf("postcapture construction: %d", code) |
| + } |
| + facts, _ := c.NamespaceFacts() |
| + calls := 0 |
| + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) |
| + store := namespaceTestCredentialClient(c, namespaceTestTransport(func(r *http.Request) (*http.Response, error) { |
| + calls++ |
| + host, path := "s3.eu-west-1.amazonaws.com", "/factory-test-bucket/root%20key" |
| + if style == "virtual" { |
| + host = "factory-test-bucket.s3.eu-west-1.amazonaws.com" |
| + path = "/root%20key" |
| + } |
| + if r.URL.Scheme != "https" || r.URL.Host != host || r.URL.EscapedPath() != path || r.Header.Get("If-None-Match") != "*" || |
| + !strings.Contains(r.Header.Get("Authorization"), "/eu-west-1/s3/aws4_request") || r.Header.Get("X-Amz-Date") != "20260102T030405Z" { |
| + t.Errorf("request %s headers %#v facts %#v", r.URL, r.Header, facts) |
| + } |
| + if r.Header.Get("X-Amz-Sdk-Checksum-Algorithm") != "" || r.Header.Get("Content-Encoding") == "aws-chunked" { |
| + t.Error("unexpected flexible checksum") |
| + } |
| + body, _ := io.ReadAll(r.Body) |
| + if string(body) != "test" { |
| + t.Errorf("body %q", body) |
| + } |
| + if calls == 1 { |
| + return namespaceTestResponse(r, 503), nil |
| + } |
| + return namespaceTestResponse(r, 200), nil |
| + })) |
| + ctx, doubt := sigfence.Track(sigfence.With(context.Background(), at)) |
| + if _, err := store.Put(ctx, "root key", []byte("test"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls != 2 || !doubt.Raised() { |
| + t.Fatalf("attempt watcher: calls=%d doubt=%v", calls, doubt.Raised()) |
| + } |
| + if facts.Bucket != "factory-test-bucket" || facts.Region != "eu-west-1" || facts.Mode != "aws" || facts.EffectivePrefix != "" || facts.AddressingStyle != style { |
| + t.Fatalf("facts %#v", facts) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestNamespaceCandidateRefusesEveryRedirect(t *testing.T) { |
| + for _, status := range []int{301, 302, 307, 308} { |
| + for _, target := range []string{"/another-bucket/key", "https://other.example.test/another-bucket/key"} { |
| + t.Run(fmt.Sprintf("%d/%s", status, target), func(t *testing.T) { |
| + c := namespaceTestCandidate(t, "factory-test-bucket", "eu-west-1", "aws", "path") |
| + calls := 0 |
| + store := namespaceTestCredentialClient(c, namespaceTestTransport(func(r *http.Request) (*http.Response, error) { |
| + calls++ |
| + resp := namespaceTestResponse(r, status) |
| + resp.Header.Set("Location", target) |
| + return resp, nil |
| + })) |
| + if _, err := store.Put(context.Background(), "key", []byte("test"), ""); err == nil { |
| + t.Fatal("redirect accepted") |
| + } |
| + if calls != 1 { |
| + t.Fatalf("redirect dispatched %d requests", calls) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestNamespaceCandidateResolverPostconditions(t *testing.T) { |
| + for _, style := range []string{"path", "virtual"} { |
| + v, _ := namespaceTestSnapshot(t, "factory-test-bucket", "eu-west-1", "aws", style).Values() |
| + resolver := namespaceResolver{values: v} |
| + valid := func() endpoints.Endpoint { |
| + ep, err := resolver.ResolveEndpoint(context.Background(), namespaceTestParams(v)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return ep |
| + } |
| + mutations := map[string]func(*endpoints.Endpoint){ |
| + "scheme": func(e *endpoints.Endpoint) { e.URI.Scheme = "http" }, |
| + "suffix-boundary": func(e *endpoints.Endpoint) { e.URI.Host = "factory-test-bucket.notamazonaws.com" }, |
| + "suffix-extension": func(e *endpoints.Endpoint) { e.URI.Host += ".evil.test" }, |
| + "bucket": func(e *endpoints.Endpoint) { |
| + if style == "path" { |
| + e.URI.Path = "/other-bucket" |
| + } else { |
| + e.URI.Host = "other-bucket.s3.eu-west-1.amazonaws.com" |
| + } |
| + }, |
| + "prefix": func(e *endpoints.Endpoint) { e.URI.Path += "/prefix" }, |
| + "userinfo": func(e *endpoints.Endpoint) { e.URI.User = url.User("user") }, |
| + "port": func(e *endpoints.Endpoint) { e.URI.Host += ":443" }, |
| + "query": func(e *endpoints.Endpoint) { e.URI.RawQuery = "x=1" }, |
| + "headers": func(e *endpoints.Endpoint) { e.Headers = http.Header{"Extra": nil} }, |
| + "express": func(e *endpoints.Endpoint) { e.Properties.Set("backend", "S3Express") }, |
| + "auth-absent": func(e *endpoints.Endpoint) { smithyauth.SetAuthOptions(&e.Properties, nil) }, |
| + "auth-extra": func(e *endpoints.Endpoint) { |
| + a, _ := smithyauth.GetAuthOptions(&e.Properties) |
| + smithyauth.SetAuthOptions(&e.Properties, append(a, a[0])) |
| + }, |
| + "auth-nil": func(e *endpoints.Endpoint) { smithyauth.SetAuthOptions(&e.Properties, []*smithyauth.Option{nil}) }, |
| + "auth-anonymous": func(e *endpoints.Endpoint) { |
| + a, _ := smithyauth.GetAuthOptions(&e.Properties) |
| + a[0].SchemeID = "smithy.api#noAuth" |
| + }, |
| + "signing-name": func(e *endpoints.Endpoint) { |
| + a, _ := smithyauth.GetAuthOptions(&e.Properties) |
| + smithyhttp.SetSigV4SigningName(&a[0].SignerProperties, "other") |
| + }, |
| + "signing-region": func(e *endpoints.Endpoint) { |
| + a, _ := smithyauth.GetAuthOptions(&e.Properties) |
| + smithyhttp.SetSigV4SigningRegion(&a[0].SignerProperties, "us-east-1") |
| + }, |
| + } |
| + for name, mutate := range mutations { |
| + t.Run(style+"/"+name, func(t *testing.T) { |
| + ep := valid() |
| + mutate(&ep) |
| + if resolver.checkEndpoint(ep) == nil { |
| + t.Fatal("accepted changed endpoint") |
| + } |
| + }) |
| + } |
| + for name, mutate := range map[string]func(*awss3.EndpointParameters){ |
| + "region": func(p *awss3.EndpointParameters) { p.Region = aws.String("unknown") }, |
| + "bucket": func(p *awss3.EndpointParameters) { p.Bucket = aws.String("other-bucket") }, |
| + "base": func(p *awss3.EndpointParameters) { p.Endpoint = aws.String("https://example.test") }, |
| + "addressing": func(p *awss3.EndpointParameters) { p.ForcePathStyle = aws.Bool(style != "path") }, |
| + "fips": func(p *awss3.EndpointParameters) { p.UseFIPS = aws.Bool(true) }, |
| + "dualstack": func(p *awss3.EndpointParameters) { p.UseDualStack = aws.Bool(true) }, |
| + "accelerate": func(p *awss3.EndpointParameters) { p.Accelerate = aws.Bool(true) }, |
| + "global": func(p *awss3.EndpointParameters) { p.UseGlobalEndpoint = aws.Bool(true) }, |
| + "mrap": func(p *awss3.EndpointParameters) { p.DisableMultiRegionAccessPoints = aws.Bool(false) }, |
| + "express": func(p *awss3.EndpointParameters) { p.DisableS3ExpressSessionAuth = aws.Bool(false) }, |
| + } { |
| + t.Run(style+"/params-"+name, func(t *testing.T) { |
| + p := namespaceTestParams(v) |
| + mutate(&p) |
| + if _, err := resolver.ResolveEndpoint(context.Background(), p); err == nil { |
| + t.Fatal("accepted routing override") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestNamespaceCandidateBucketRefusals(t *testing.T) { |
| + for _, bucket := range []string{"ab", strings.Repeat("a", 64), "Bad-bucket", "bad_bucket", "-bucket", "bucket-", "bucket..name", "192.168.5.4", "999.999.999.999", "xn--bucket", "sthree-bucket", "bucket-s3alias", "bucket--ol-s3", "bucket.mrap", "bucket--x-s3"} { |
| + for _, style := range []string{"path", "virtual"} { |
| + if _, code := NewNamespaceCandidate(namespaceTestSnapshot(t, bucket, "eu-west-1", "aws", style)); code != NamespaceRefuseBucket { |
| + t.Fatalf("accepted bucket %q: %d", bucket, code) |
| + } |
| + } |
| + } |
| + if _, code := NewNamespaceCandidate(namespaceTestSnapshot(t, "dotted.bucket", "eu-west-1", "aws", "virtual")); code != NamespaceRefuseBucket { |
| + t.Fatal("dotted virtual") |
| + } |
| + namespaceTestCandidate(t, "dotted.bucket", "eu-west-1", "aws", "path") |
| +} |
| + |
| +func TestNamespaceCandidateStaticBoundary(t *testing.T) { |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, "namespacecandidate.go", nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases := map[string]string{} |
| + for _, imp := range file.Imports { |
| + path := strings.Trim(imp.Path.Value, `"`) |
| + name := filepath.Base(path) |
| + if imp.Name != nil { |
| + name = imp.Name.Name |
| + } |
| + aliases[name] = path |
| + if path == "os" || path == "github.com/aws/aws-sdk-go-v2/config" || path == "github.com/carlosframework/platform/internal/console" { |
| + t.Fatalf("forbidden factory import %s", path) |
| + } |
| + } |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + if sel, ok := n.(*ast.SelectorExpr); ok { |
| + if sel.Sel.Name == "LoadDefaultConfig" || sel.Sel.Name == "NewFromConfig" || sel.Sel.Name == "Getenv" || sel.Sel.Name == "LookupEnv" { |
| + t.Errorf("forbidden %s", sel.Sel.Name) |
| + } |
| + if id, ok := sel.X.(*ast.Ident); ok && aliases[id.Name] == "github.com/carlosframework/platform/internal/s3compat" && (sel.Sel.Name == "Options" || sel.Sel.Name == "Quirks" || sel.Sel.Name == "Endpoint") { |
| + t.Errorf("ambient %s", sel.Sel.Name) |
| + } |
| + } |
| + return true |
| + }) |
| +} |
| + |
| +func TestNamespaceCandidateEveryClosedRegion(t *testing.T) { |
| + cases := []struct{ partition, region, suffix string }{ |
| + {"aws", "af-south-1", "amazonaws.com"}, |
| + {"aws", "ap-east-1", "amazonaws.com"}, |
| + {"aws", "ap-east-2", "amazonaws.com"}, |
| + {"aws", "ap-northeast-1", "amazonaws.com"}, |
| + {"aws", "ap-northeast-2", "amazonaws.com"}, |
| + {"aws", "ap-northeast-3", "amazonaws.com"}, |
| + {"aws", "ap-south-1", "amazonaws.com"}, |
| + {"aws", "ap-south-2", "amazonaws.com"}, |
| + {"aws", "ap-southeast-1", "amazonaws.com"}, |
| + {"aws", "ap-southeast-2", "amazonaws.com"}, |
| + {"aws", "ap-southeast-3", "amazonaws.com"}, |
| + {"aws", "ap-southeast-4", "amazonaws.com"}, |
| + {"aws", "ap-southeast-5", "amazonaws.com"}, |
| + {"aws", "ap-southeast-6", "amazonaws.com"}, |
| + {"aws", "ap-southeast-7", "amazonaws.com"}, |
| + {"aws", "ca-central-1", "amazonaws.com"}, |
| + {"aws", "ca-west-1", "amazonaws.com"}, |
| + {"aws", "eu-central-1", "amazonaws.com"}, |
| + {"aws", "eu-central-2", "amazonaws.com"}, |
| + {"aws", "eu-north-1", "amazonaws.com"}, |
| + {"aws", "eu-south-1", "amazonaws.com"}, |
| + {"aws", "eu-south-2", "amazonaws.com"}, |
| + {"aws", "eu-west-1", "amazonaws.com"}, |
| + {"aws", "eu-west-2", "amazonaws.com"}, |
| + {"aws", "eu-west-3", "amazonaws.com"}, |
| + {"aws", "il-central-1", "amazonaws.com"}, |
| + {"aws", "me-central-1", "amazonaws.com"}, |
| + {"aws", "me-south-1", "amazonaws.com"}, |
| + {"aws", "mx-central-1", "amazonaws.com"}, |
| + {"aws", "sa-east-1", "amazonaws.com"}, |
| + {"aws", "us-east-1", "amazonaws.com"}, |
| + {"aws", "us-east-2", "amazonaws.com"}, |
| + {"aws", "us-west-1", "amazonaws.com"}, |
| + {"aws", "us-west-2", "amazonaws.com"}, |
| + {"aws-cn", "cn-north-1", "amazonaws.com.cn"}, |
| + {"aws-cn", "cn-northwest-1", "amazonaws.com.cn"}, |
| + {"aws-eusc", "eusc-de-east-1", "amazonaws.eu"}, |
| + {"aws-iso", "us-iso-east-1", "c2s.ic.gov"}, |
| + {"aws-iso", "us-iso-west-1", "c2s.ic.gov"}, |
| + {"aws-iso-b", "us-isob-east-1", "sc2s.sgov.gov"}, |
| + {"aws-iso-b", "us-isob-west-1", "sc2s.sgov.gov"}, |
| + {"aws-iso-e", "eu-isoe-west-1", "cloud.adc-e.uk"}, |
| + {"aws-iso-f", "us-isof-east-1", "csp.hci.ic.gov"}, |
| + {"aws-iso-f", "us-isof-south-1", "csp.hci.ic.gov"}, |
| + {"aws-us-gov", "us-gov-east-1", "amazonaws.com"}, |
| + {"aws-us-gov", "us-gov-west-1", "amazonaws.com"}, |
| + } |
| + for _, tc := range cases { |
| + for _, style := range []string{"path", "virtual"} { |
| + t.Run(tc.partition+"/"+tc.region+"/"+style, func(t *testing.T) { |
| + snapshot := namespaceTestSnapshot(t, "factory-test-bucket", tc.region, tc.partition, style) |
| + v, _ := snapshot.Values() |
| + if v.DNSSuffix != tc.suffix { |
| + t.Fatalf("suffix %q", v.DNSSuffix) |
| + } |
| + r := namespaceResolver{values: v} |
| + ep, err := r.ResolveEndpoint(context.Background(), namespaceTestParams(v)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wantHost := "s3." + tc.region + "." + tc.suffix |
| + wantPath := "/factory-test-bucket" |
| + if style == "virtual" { |
| + wantHost = "factory-test-bucket." + wantHost |
| + wantPath = "/" |
| + } |
| + if ep.URI.Host != wantHost || strings.TrimSuffix(ep.URI.Path, "/") != strings.TrimSuffix(wantPath, "/") { |
| + t.Fatalf("endpoint %s, want host %s path %s", ep.URI.String(), wantHost, wantPath) |
| + } |
| + c, code := NewNamespaceCandidate(snapshot) |
| + if code != NamespaceAccepted { |
| + t.Fatalf("construct %d", code) |
| + } |
| + calls := 0 |
| + store := namespaceTestCredentialClient(c, namespaceTestTransport(func(req *http.Request) (*http.Response, error) { |
| + calls++ |
| + if req.URL.Host != wantHost || !strings.Contains(req.Header.Get("Authorization"), "/"+tc.region+"/s3/aws4_request") { |
| + t.Errorf("request %s %v", req.URL, req.Header) |
| + } |
| + return namespaceTestResponse(req, 200), nil |
| + })) |
| + if _, err := store.Put(context.Background(), "key", []byte("test"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls != 1 { |
| + t.Fatalf("calls %d", calls) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestNamespaceCandidateCredentialProviderStaticBoundary(t *testing.T) { |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, "namespacecredentials.go", nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, imp := range file.Imports { |
| + path := strings.Trim(imp.Path.Value, `"`) |
| + if path != "context" && path != "github.com/aws/aws-sdk-go-v2/aws" { |
| + t.Fatalf("provider I/O import %s", path) |
| + } |
| + } |
| + for _, decl := range file.Decls { |
| + if g, ok := decl.(*ast.GenDecl); ok && g.Tok == token.VAR { |
| + t.Fatal("mutable provider/refusal state") |
| + } |
| + } |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + if c, ok := n.(*ast.CallExpr); ok { |
| + t.Errorf("provider has effectful call %s", fset.Position(c.Pos())) |
| + } |
| + return true |
| + }) |
| +} |
| + |
| +func TestNamespaceCandidateProductionCallersStayUnmounted(t *testing.T) { |
| + fset := token.NewFileSet() |
| + for _, dir := range []string{".", "../s3compat"} { |
| + files, err := filepath.Glob(filepath.Join(dir, "*.go")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range files { |
| + base := filepath.Base(name) |
| + if strings.HasSuffix(name, "_test.go") || base == "namespacecandidate.go" || base == "namespacecredentials.go" || base == "awsprofile.go" { |
| + continue |
| + } |
| + file, err := parser.ParseFile(fset, name, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + if id, ok := n.(*ast.Ident); ok { |
| + switch id.Name { |
| + case "NamespaceCandidate", "NewNamespaceCandidate", "namespaceOptions", "namespaceResolver", "namespaceRefusingCredentials", "CaptureAWSProfile", "AWSProfileSnapshot": |
| + t.Errorf("new production reference %s at %s", id.Name, fset.Position(id.Pos())) |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/release/namespacecredentials.go b/internal/release/namespacecredentials.go| new file mode 100644 |
| index 000000000..c7a1e75af |
| --- /dev/null |
| +++ b/internal/release/namespacecredentials.go |
| @@ -0,0 +1,19 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| +) |
| + |
| +type namespaceCredentialsRefusal uint8 |
| + |
| +const namespaceCredentialsUnavailable namespaceCredentialsRefusal = 1 |
| + |
| +func (namespaceCredentialsRefusal) Error() string { return "1" } |
| + |
| +type namespaceRefusingCredentials struct{} |
| + |
| +func (namespaceRefusingCredentials) Retrieve(context.Context) (aws.Credentials, error) { |
| + return aws.Credentials{}, namespaceCredentialsUnavailable |
| +} |
diff --git a/internal/s3compat/awsprofile.go b/internal/s3compat/awsprofile.go| new file mode 100644 |
| index 000000000..cca619f25 |
| --- /dev/null |
| +++ b/internal/s3compat/awsprofile.go |
| @@ -0,0 +1,114 @@ |
| +package s3compat |
| + |
| +type AWSProfileRefusal uint8 |
| + |
| +const ( |
| + AWSProfileAccepted AWSProfileRefusal = iota |
| + AWSProfileRefuseInput |
| + AWSProfileRefuseOverride |
| + AWSProfileRefuseRegion |
| +) |
| + |
| +type AWSProfileValues struct { |
| + Bucket string |
| + Region string |
| + Partition string |
| + AddressingStyle string |
| + DNSSuffix string |
| + Revision string |
| +} |
| + |
| +type AWSProfileSnapshot struct { |
| + values AWSProfileValues |
| + captured bool |
| +} |
| + |
| +func (s AWSProfileSnapshot) Values() (AWSProfileValues, AWSProfileRefusal) { |
| + if !s.captured { |
| + return AWSProfileValues{}, AWSProfileRefuseInput |
| + } |
| + return s.values, AWSProfileAccepted |
| +} |
| + |
| +func CaptureAWSProfile(lookup func(string) (string, bool)) (AWSProfileSnapshot, AWSProfileRefusal) { |
| + if lookup == nil { |
| + return AWSProfileSnapshot{}, AWSProfileRefuseInput |
| + } |
| + var values [4]string |
| + missing := false |
| + for i, key := range [...]string{"CARLOS_DEPLOYMENT_BUCKET", "AWS_REGION", "CARLOS_S3_PARTITION", "CARLOS_S3_ADDRESSING_STYLE"} { |
| + value, present := lookup(key) |
| + values[i] = value |
| + missing = missing || !present || value == "" |
| + } |
| + overridden := false |
| + for _, key := range [...]string{ |
| + "CARLOS_S3_ENDPOINT", "CARLOS_S3_QUIRKS", "AWS_ENDPOINT_URL", "AWS_ENDPOINT_URL_S3", |
| + "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "AWS_USE_FIPS_ENDPOINT", "AWS_USE_DUALSTACK_ENDPOINT", |
| + "AWS_S3_USE_ARN_REGION", "AWS_CA_BUNDLE", "AWS_AUTH_SCHEME_PREFERENCE", |
| + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", |
| + } { |
| + _, present := lookup(key) |
| + overridden = overridden || present |
| + } |
| + if overridden { |
| + return AWSProfileSnapshot{}, AWSProfileRefuseOverride |
| + } |
| + if missing || (values[3] != "path" && values[3] != "virtual") { |
| + return AWSProfileSnapshot{}, AWSProfileRefuseInput |
| + } |
| + suffix := awsProfileDNSSuffix(values[2], values[1]) |
| + if suffix == "" { |
| + return AWSProfileSnapshot{}, AWSProfileRefuseRegion |
| + } |
| + return AWSProfileSnapshot{values: AWSProfileValues{ |
| + Bucket: values[0], Region: values[1], Partition: values[2], AddressingStyle: values[3], |
| + DNSSuffix: suffix, Revision: "aws-root-v1-sdk-s3-1-105-0", |
| + }, captured: true}, AWSProfileAccepted |
| +} |
| + |
| +func awsProfileDNSSuffix(partition, region string) string { |
| + switch partition { |
| + case "aws": |
| + switch region { |
| + case "af-south-1", "ap-east-1", "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", "il-central-1", "me-central-1", "me-south-1", "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2": |
| + return "amazonaws.com" |
| + } |
| + case "aws-cn": |
| + switch region { |
| + case "cn-north-1", "cn-northwest-1": |
| + return "amazonaws.com.cn" |
| + } |
| + case "aws-eusc": |
| + switch region { |
| + case "eusc-de-east-1": |
| + return "amazonaws.eu" |
| + } |
| + case "aws-iso": |
| + switch region { |
| + case "us-iso-east-1", "us-iso-west-1": |
| + return "c2s.ic.gov" |
| + } |
| + case "aws-iso-b": |
| + switch region { |
| + case "us-isob-east-1", "us-isob-west-1": |
| + return "sc2s.sgov.gov" |
| + } |
| + case "aws-iso-e": |
| + switch region { |
| + case "eu-isoe-west-1": |
| + return "cloud.adc-e.uk" |
| + } |
| + case "aws-iso-f": |
| + switch region { |
| + case "us-isof-east-1", "us-isof-south-1": |
| + return "csp.hci.ic.gov" |
| + } |
| + case "aws-us-gov": |
| + switch region { |
| + case "us-gov-east-1", "us-gov-west-1": |
| + return "amazonaws.com" |
| + } |
| + } |
| + return "" |
| +} |
diff --git a/internal/s3compat/awsprofile_test.go b/internal/s3compat/awsprofile_test.go| new file mode 100644 |
| index 000000000..f1db0c5cc |
| --- /dev/null |
| +++ b/internal/s3compat/awsprofile_test.go |
| @@ -0,0 +1,101 @@ |
| +package s3compat |
| + |
| +import ( |
| + "reflect" |
| + "testing" |
| +) |
| + |
| +func awsProfileTestEnv() map[string]string { |
| + return map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "factory-test-bucket", "AWS_REGION": "eu-west-1", "CARLOS_S3_PARTITION": "aws", "CARLOS_S3_ADDRESSING_STYLE": "path"} |
| +} |
| + |
| +func awsProfileTestKeys() []string { |
| + return []string{"CARLOS_DEPLOYMENT_BUCKET", "AWS_REGION", "CARLOS_S3_PARTITION", "CARLOS_S3_ADDRESSING_STYLE", |
| + "CARLOS_S3_ENDPOINT", "CARLOS_S3_QUIRKS", "AWS_ENDPOINT_URL", "AWS_ENDPOINT_URL_S3", "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", |
| + "AWS_USE_FIPS_ENDPOINT", "AWS_USE_DUALSTACK_ENDPOINT", "AWS_S3_USE_ARN_REGION", "AWS_CA_BUNDLE", "AWS_AUTH_SCHEME_PREFERENCE", |
| + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"} |
| +} |
| + |
| +func TestAWSProfileCaptureReadsExactlyOnceAndCopies(t *testing.T) { |
| + values := awsProfileTestEnv() |
| + reads := map[string]int{} |
| + snapshot, code := CaptureAWSProfile(func(key string) (string, bool) { reads[key]++; v, ok := values[key]; return v, ok }) |
| + if code != AWSProfileAccepted { |
| + t.Fatalf("capture: %d", code) |
| + } |
| + want := map[string]int{} |
| + for _, key := range awsProfileTestKeys() { |
| + want[key] = 1 |
| + values[key] = "changed" |
| + } |
| + if !reflect.DeepEqual(reads, want) { |
| + t.Fatalf("reads = %#v", reads) |
| + } |
| + before, _ := snapshot.Values() |
| + copy, _ := snapshot.Values() |
| + rv := reflect.ValueOf(©).Elem() |
| + for i := 0; i < rv.NumField(); i++ { |
| + rv.Field(i).SetString("mutated") |
| + } |
| + after, code := snapshot.Values() |
| + if code != AWSProfileAccepted || after != before || before.Bucket != "factory-test-bucket" || before.Region != "eu-west-1" || before.Revision != "aws-root-v1-sdk-s3-1-105-0" { |
| + t.Fatalf("snapshot changed: %#v", after) |
| + } |
| + if !reflect.DeepEqual(reads, want) { |
| + t.Fatal("lookup retained") |
| + } |
| +} |
| + |
| +func TestAWSProfileCaptureRefusals(t *testing.T) { |
| + for _, key := range awsProfileTestKeys()[4:] { |
| + for _, value := range []string{"", "configured"} { |
| + t.Run(key+"/"+value, func(t *testing.T) { |
| + values := awsProfileTestEnv() |
| + values[key] = value |
| + reads := map[string]int{} |
| + snapshot, code := CaptureAWSProfile(func(k string) (string, bool) { reads[k]++; v, ok := values[k]; return v, ok }) |
| + if code != AWSProfileRefuseOverride { |
| + t.Fatalf("accepted override: %d", code) |
| + } |
| + if _, code := snapshot.Values(); code == AWSProfileAccepted { |
| + t.Fatal("refused capture returned usable snapshot") |
| + } |
| + for _, k := range awsProfileTestKeys() { |
| + if reads[k] != 1 { |
| + t.Fatalf("reads[%s]=%d", k, reads[k]) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| + for _, key := range awsProfileTestKeys()[:4] { |
| + for _, empty := range []bool{false, true} { |
| + values := awsProfileTestEnv() |
| + delete(values, key) |
| + if empty { |
| + values[key] = "" |
| + } |
| + if _, code := CaptureAWSProfile(func(k string) (string, bool) { v, ok := values[k]; return v, ok }); code == AWSProfileAccepted { |
| + t.Fatalf("accepted missing %s", key) |
| + } |
| + } |
| + } |
| + for _, change := range []map[string]string{ |
| + {"AWS_REGION": "eu-future-99"}, {"AWS_REGION": "aws-global"}, {"CARLOS_S3_PARTITION": "unknown"}, |
| + {"CARLOS_S3_PARTITION": "aws-cn"}, {"AWS_REGION": " us-east-1"}, {"CARLOS_S3_ADDRESSING_STYLE": "auto"}, |
| + } { |
| + values := awsProfileTestEnv() |
| + for k, v := range change { |
| + values[k] = v |
| + } |
| + if _, code := CaptureAWSProfile(func(k string) (string, bool) { v, ok := values[k]; return v, ok }); code == AWSProfileAccepted { |
| + t.Fatalf("accepted %#v", change) |
| + } |
| + } |
| + if _, code := CaptureAWSProfile(nil); code == AWSProfileAccepted { |
| + t.Fatal("nil lookup") |
| + } |
| + if _, code := (AWSProfileSnapshot{}).Values(); code == AWSProfileAccepted { |
| + t.Fatal("zero snapshot") |
| + } |
| +} |