diff --git a/weed/admin/dash/s3tables_management.go b/weed/admin/dash/s3tables_management.go index be9ff84b0..0c38c58a0 100644 --- a/weed/admin/dash/s3tables_management.go +++ b/weed/admin/dash/s3tables_management.go @@ -63,6 +63,19 @@ type tableBucketMetadata struct { const s3TablesAdminListLimit = 1000 +func parseNamespaceInput(namespace string) ([]string, error) { + return s3tables.ParseNamespace(namespace) +} + +func (s *AdminServer) parseNamespaceFromGin(c *gin.Context, namespace string) ([]string, bool) { + parts, err := parseNamespaceInput(namespace) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid namespace: " + err.Error()}) + return nil, false + } + return parts, true +} + func newS3TablesManager() *s3tables.Manager { manager := s3tables.NewManager() manager.SetAccountID(s3_constants.AccountAdminId) @@ -158,7 +171,11 @@ func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, name var resp s3tables.ListTablesResponse var ns []string if namespace != "" { - ns = []string{namespace} + parts, err := parseNamespaceInput(namespace) + if err != nil { + return S3TablesTablesData{}, err + } + ns = parts } req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit} if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil { @@ -257,9 +274,13 @@ func (s *AdminServer) GetIcebergTablesData(ctx context.Context, catalogName, buc // GetIcebergTableDetailsData returns Iceberg table metadata and snapshot information. func (s *AdminServer) GetIcebergTableDetailsData(ctx context.Context, catalogName, bucketArn, namespace, tableName string) (IcebergTableDetailsData, error) { var resp s3tables.GetTableResponse + namespaceParts, err := parseNamespaceInput(namespace) + if err != nil { + return IcebergTableDetailsData{}, err + } req := &s3tables.GetTableRequest{ TableBucketARN: bucketArn, - Namespace: []string{namespace}, + Namespace: namespaceParts, Name: tableName, } if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil { @@ -686,7 +707,11 @@ func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket_arn and name are required"}) return } - createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}} + namespaceParts, ok := s.parseNamespaceFromGin(c, req.Name) + if !ok { + return + } + createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts} var resp s3tables.CreateNamespaceResponse if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil { writeS3TablesError(c, err) @@ -705,7 +730,11 @@ func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket and name query parameters are required"}) return } - req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}} + namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + if !ok { + return + } + req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts} if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil { writeS3TablesError(c, err) return @@ -745,6 +774,10 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"}) return } + namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace) + if !ok { + return + } format := req.Format if format == "" { format = "ICEBERG" @@ -757,7 +790,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { } createReq := &s3tables.CreateTableRequest{ TableBucketARN: req.BucketARN, - Namespace: []string{req.Namespace}, + Namespace: namespaceParts, Name: req.Name, Format: format, Tags: req.Tags, @@ -780,7 +813,11 @@ func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) return } - req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version} + namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + if !ok { + return + } + req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version} if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil { writeS3TablesError(c, err) return @@ -853,7 +890,11 @@ func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"}) return } - putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy} + namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace) + if !ok { + return + } + putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy} if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil { writeS3TablesError(c, err) return @@ -869,7 +910,11 @@ func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) return } - getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name} + namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + if !ok { + return + } + getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name} var resp s3tables.GetTablePolicyResponse if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil { writeS3TablesError(c, err) @@ -886,7 +931,11 @@ func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) { c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) return } - deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name} + namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + if !ok { + return + } + deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name} if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil { writeS3TablesError(c, err) return diff --git a/weed/admin/static/js/s3tables.js b/weed/admin/static/js/s3tables.js index 81fa004b9..3d5816bb2 100644 --- a/weed/admin/static/js/s3tables.js +++ b/weed/admin/static/js/s3tables.js @@ -725,20 +725,27 @@ function s3TablesNamespaceNameError(name) { if (name.includes('/')) { return "namespace name cannot contain '/'"; } - if (!isLowercaseLetterOrDigit(name[0])) { - return 'Namespace name must start with a letter or digit'; - } - if (!isLowercaseLetterOrDigit(name[name.length - 1])) { - return 'Namespace name must end with a letter or digit'; - } - for (const ch of name) { - if (isLowercaseLetterOrDigit(ch) || ch === '_') { - continue; + + const parts = name.split('.'); + for (const part of parts) { + if (!part) { + return 'namespace levels cannot be empty'; + } + if (!isLowercaseLetterOrDigit(part[0])) { + return 'Namespace name must start with a letter or digit'; + } + if (!isLowercaseLetterOrDigit(part[part.length - 1])) { + return 'Namespace name must end with a letter or digit'; + } + for (const ch of part) { + if (isLowercaseLetterOrDigit(ch) || ch === '_') { + continue; + } + return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed"; + } + if (part.startsWith('aws')) { + return "namespace name cannot start with reserved prefix 'aws'"; } - return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed"; - } - if (name.startsWith('aws')) { - return "namespace name cannot start with reserved prefix 'aws'"; } return ''; } diff --git a/weed/admin/view/app/iceberg_namespaces.templ b/weed/admin/view/app/iceberg_namespaces.templ index ebfbef94d..0b78577a9 100644 --- a/weed/admin/view/app/iceberg_namespaces.templ +++ b/weed/admin/view/app/iceberg_namespaces.templ @@ -195,7 +195,7 @@ templ IcebergNamespaces(data dash.IcebergNamespacesData) {
-
Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.
+
Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">
Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/s3tables_namespaces.templ b/weed/admin/view/app/s3tables_namespaces.templ index c61132177..4c203ea19 100644 --- a/weed/admin/view/app/s3tables_namespaces.templ +++ b/weed/admin/view/app/s3tables_namespaces.templ @@ -154,14 +154,14 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
-
Delete Namespace

Are you sure you want to delete the namespace ?

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">
Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).
Delete Namespace

Are you sure you want to delete the namespace ?

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/s3api/s3api_tables.go b/weed/s3api/s3api_tables.go index b93d5879f..2b5d352b9 100644 --- a/weed/s3api/s3api_tables.go +++ b/weed/s3api/s3api_tables.go @@ -231,11 +231,23 @@ func parseOptionalNamespace(r *http.Request, name string) []string { if value == "" { return nil } - if _, err := s3tables.ValidateNamespace([]string{value}); err != nil { + parts, err := s3tables.ParseNamespace(value) + if err != nil { glog.V(1).Infof("invalid namespace value for %s: %q: %v", name, value, err) return nil } - return []string{value} + return parts +} + +func parseRequiredNamespacePathParam(r *http.Request, name string) ([]string, error) { + value, err := getDecodedPathParam(r, name) + if err != nil { + return nil, err + } + if value == "" { + return nil, fmt.Errorf("%s is required", name) + } + return s3tables.ParseNamespace(value) } // parseTagKeys handles tag key parsing from query parameters. @@ -352,19 +364,13 @@ func buildGetNamespaceRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } return &s3tables.GetNamespaceRequest{ TableBucketARN: tableBucketARN, - Namespace: []string{namespace}, + Namespace: namespace, }, nil } @@ -373,19 +379,13 @@ func buildDeleteNamespaceRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } return &s3tables.DeleteNamespaceRequest{ TableBucketARN: tableBucketARN, - Namespace: []string{namespace}, + Namespace: namespace, }, nil } @@ -398,18 +398,12 @@ func buildCreateTableRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } req.TableBucketARN = tableBucketARN - req.Namespace = []string{namespace} + req.Namespace = namespace return &req, nil } @@ -453,16 +447,10 @@ func buildDeleteTableRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } name, err := getDecodedPathParam(r, "name") if err != nil { return nil, err @@ -475,7 +463,7 @@ func buildDeleteTableRequest(r *http.Request) (interface{}, error) { } return &s3tables.DeleteTableRequest{ TableBucketARN: tableBucketARN, - Namespace: []string{namespace}, + Namespace: namespace, Name: name, VersionToken: r.URL.Query().Get("versionToken"), }, nil @@ -490,16 +478,10 @@ func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } name, err := getDecodedPathParam(r, "name") if err != nil { return nil, err @@ -511,7 +493,7 @@ func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) { return nil, err } req.TableBucketARN = tableBucketARN - req.Namespace = []string{namespace} + req.Namespace = namespace req.Name = name return &req, nil } @@ -521,16 +503,10 @@ func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } name, err := getDecodedPathParam(r, "name") if err != nil { return nil, err @@ -543,7 +519,7 @@ func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) { } return &s3tables.GetTablePolicyRequest{ TableBucketARN: tableBucketARN, - Namespace: []string{namespace}, + Namespace: namespace, Name: name, }, nil } @@ -553,16 +529,10 @@ func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) { if err != nil { return nil, err } - namespace, err := getDecodedPathParam(r, "namespace") + namespace, err := parseRequiredNamespacePathParam(r, "namespace") if err != nil { return nil, err } - if namespace == "" { - return nil, fmt.Errorf("namespace is required") - } - if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { - return nil, err - } name, err := getDecodedPathParam(r, "name") if err != nil { return nil, err @@ -575,7 +545,7 @@ func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) { } return &s3tables.DeleteTablePolicyRequest{ TableBucketARN: tableBucketARN, - Namespace: []string{namespace}, + Namespace: namespace, Name: name, }, nil } diff --git a/weed/s3api/s3tables/handler_table.go b/weed/s3api/s3tables/handler_table.go index 77e6691fd..48b9b78c0 100644 --- a/weed/s3api/s3tables/handler_table.go +++ b/weed/s3api/s3tables/handler_table.go @@ -408,7 +408,7 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request, resp := &GetTableResponse{ Name: metadata.Name, TableARN: tableARN, - Namespace: []string{metadata.Namespace}, + Namespace: expandNamespace(metadata.Namespace), Format: metadata.Format, CreatedAt: metadata.CreatedAt, ModifiedAt: metadata.ModifiedAt, @@ -683,7 +683,7 @@ func (h *S3TablesHandler) listTablesWithClient(r *http.Request, client filer_pb. tables = append(tables, TableSummary{ Name: entry.Entry.Name, TableARN: tableARN, - Namespace: []string{namespaceName}, + Namespace: expandNamespace(namespaceName), CreatedAt: metadata.CreatedAt, ModifiedAt: metadata.ModifiedAt, }) diff --git a/weed/s3api/s3tables/utils.go b/weed/s3api/s3tables/utils.go index f6be5f530..a969cba3b 100644 --- a/weed/s3api/s3tables/utils.go +++ b/weed/s3api/s3tables/utils.go @@ -15,7 +15,7 @@ import ( const ( bucketNamePatternStr = `[a-z0-9-]+` - tableNamespacePatternStr = `[a-z0-9_]+` + tableNamespacePatternStr = `[a-z0-9_.]+` tableNamePatternStr = `[a-z0-9_]+` ) @@ -54,7 +54,6 @@ func ParseBucketNameFromARN(arn string) (string, error) { // parseTableFromARN extracts bucket name, namespace, and table name from ARN // ARN format: arn:aws:s3tables:{region}:{account}:bucket/{bucket-name}/table/{namespace}/{table-name} func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err error) { - // Updated regex to align with namespace validation (single-segment) matches := tableARNPattern.FindStringSubmatch(arn) if len(matches) != 4 { return "", "", "", fmt.Errorf("invalid table ARN: %s", arn) @@ -66,9 +65,7 @@ func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err return "", "", "", fmt.Errorf("invalid bucket name in ARN: %v", err) } - // Namespace is already constrained by the regex; validate it directly. - namespace = matches[2] - _, err = validateNamespace([]string{namespace}) + namespace, err = validateNamespace([]string{matches[2]}) if err != nil { return "", "", "", fmt.Errorf("invalid namespace in ARN: %v", err) } @@ -326,35 +323,27 @@ func splitPath(p string) (dir, name string) { return } -// validateNamespace validates that the namespace provided is supported (single-level) -func validateNamespace(namespace []string) (string, error) { - if len(namespace) == 0 { - return "", fmt.Errorf("namespace is required") - } - if len(namespace) > 1 { - return "", fmt.Errorf("multi-level namespaces are not supported") - } - name := namespace[0] +func validateNamespacePart(name string) error { if len(name) < 1 || len(name) > 255 { - return "", fmt.Errorf("namespace name must be between 1 and 255 characters") + return fmt.Errorf("namespace name must be between 1 and 255 characters") } // Prevent path traversal and multi-segment paths if name == "." || name == ".." { - return "", fmt.Errorf("namespace name cannot be '.' or '..'") + return fmt.Errorf("namespace name cannot be '.' or '..'") } if strings.Contains(name, "/") { - return "", fmt.Errorf("namespace name cannot contain '/'") + return fmt.Errorf("namespace name cannot contain '/'") } // Must start and end with a letter or digit start := name[0] end := name[len(name)-1] if !((start >= 'a' && start <= 'z') || (start >= '0' && start <= '9')) { - return "", fmt.Errorf("namespace name must start with a letter or digit") + return fmt.Errorf("namespace name must start with a letter or digit") } if !((end >= 'a' && end <= 'z') || (end >= '0' && end <= '9')) { - return "", fmt.Errorf("namespace name must end with a letter or digit") + return fmt.Errorf("namespace name must end with a letter or digit") } // Allowed characters: a-z, 0-9, _ @@ -362,15 +351,46 @@ func validateNamespace(namespace []string) (string, error) { if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' { continue } - return "", fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed") + return fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed") } // Reserved prefix if strings.HasPrefix(name, "aws") { - return "", fmt.Errorf("namespace name cannot start with reserved prefix 'aws'") + return fmt.Errorf("namespace name cannot start with reserved prefix 'aws'") } - return name, nil + return nil +} + +func normalizeNamespace(namespace []string) ([]string, error) { + if len(namespace) == 0 { + return nil, fmt.Errorf("namespace is required") + } + + parts := namespace + if len(namespace) == 1 { + parts = strings.Split(namespace[0], ".") + } + + normalized := make([]string, 0, len(parts)) + for _, part := range parts { + if err := validateNamespacePart(part); err != nil { + return nil, err + } + normalized = append(normalized, part) + } + return normalized, nil +} + +// validateNamespace validates namespace identifiers and returns an internal namespace key. +// A single dotted namespace value is interpreted as multi-level namespace for compatibility +// with path-style APIs, for example "analytics.daily" => ["analytics", "daily"]. +func validateNamespace(namespace []string) (string, error) { + parts, err := normalizeNamespace(namespace) + if err != nil { + return "", err + } + return flattenNamespace(parts), nil } // ValidateNamespace is a wrapper to validate namespace for other packages. @@ -378,6 +398,11 @@ func ValidateNamespace(namespace []string) (string, error) { return validateNamespace(namespace) } +// ParseNamespace parses a namespace string into namespace parts. +func ParseNamespace(namespace string) ([]string, error) { + return normalizeNamespace([]string{namespace}) +} + // validateTableName validates a table name func validateTableName(name string) (string, error) { if len(name) < 1 || len(name) > 255 { @@ -415,3 +440,14 @@ func flattenNamespace(namespace []string) string { } return strings.Join(namespace, ".") } + +func expandNamespace(namespace string) []string { + if namespace == "" { + return nil + } + parts, err := ParseNamespace(namespace) + if err != nil { + return []string{namespace} + } + return parts +} diff --git a/weed/s3api/s3tables/utils_namespace_test.go b/weed/s3api/s3tables/utils_namespace_test.go new file mode 100644 index 000000000..1081d024c --- /dev/null +++ b/weed/s3api/s3tables/utils_namespace_test.go @@ -0,0 +1,126 @@ +package s3tables + +import ( + "strings" + "testing" +) + +func TestValidateNamespaceSupportsMultiLevel(t *testing.T) { + got, err := validateNamespace([]string{"analytics", "daily"}) + if err != nil { + t.Fatalf("validateNamespace returned error: %v", err) + } + if got != "analytics.daily" { + t.Fatalf("validateNamespace = %q, want %q", got, "analytics.daily") + } +} + +func TestValidateNamespaceSupportsDottedInput(t *testing.T) { + got, err := validateNamespace([]string{"analytics.daily"}) + if err != nil { + t.Fatalf("validateNamespace returned error: %v", err) + } + if got != "analytics.daily" { + t.Fatalf("validateNamespace = %q, want %q", got, "analytics.daily") + } +} + +func TestValidateNamespaceRejectsEmptyDottedSegment(t *testing.T) { + _, err := validateNamespace([]string{"analytics..daily"}) + if err == nil { + t.Fatalf("expected validateNamespace to fail for empty dotted segment") + } +} + +func TestParseNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + want []string + wantErr bool + }{ + { + name: "single level", + namespace: "analytics", + want: []string{"analytics"}, + }, + { + name: "multi level dotted", + namespace: "analytics.daily", + want: []string{"analytics", "daily"}, + }, + { + name: "invalid reserved prefix", + namespace: "analytics.awsprod", + wantErr: true, + }, + { + name: "invalid empty segment", + namespace: "analytics..daily", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseNamespace(tt.namespace) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseNamespace(%q) expected error", tt.namespace) + } + return + } + if err != nil { + t.Fatalf("ParseNamespace(%q) unexpected error: %v", tt.namespace, err) + } + if len(got) != len(tt.want) { + t.Fatalf("ParseNamespace(%q) = %v, want %v", tt.namespace, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("ParseNamespace(%q) = %v, want %v", tt.namespace, got, tt.want) + } + } + }) + } +} + +func TestParseTableFromARNWithMultiLevelNamespace(t *testing.T) { + arn := "arn:aws:s3tables:us-east-1:123456789012:bucket/testbucket/table/analytics.daily/events" + bucket, namespace, table, err := parseTableFromARN(arn) + if err != nil { + t.Fatalf("parseTableFromARN returned error: %v", err) + } + if bucket != "testbucket" { + t.Fatalf("bucket = %q, want %q", bucket, "testbucket") + } + if namespace != "analytics.daily" { + t.Fatalf("namespace = %q, want %q", namespace, "analytics.daily") + } + if table != "events" { + t.Fatalf("table = %q, want %q", table, "events") + } +} + +func TestBuildTableARNWithDottedNamespace(t *testing.T) { + arn, err := BuildTableARN("us-east-1", "123456789012", "testbucket", "analytics.daily", "events") + if err != nil { + t.Fatalf("BuildTableARN returned error: %v", err) + } + if !strings.Contains(arn, "/table/analytics.daily/events") { + t.Fatalf("BuildTableARN returned %q, missing normalized namespace/table path", arn) + } +} + +func TestExpandNamespace(t *testing.T) { + got := expandNamespace("analytics.daily") + want := []string{"analytics", "daily"} + if len(got) != len(want) { + t.Fatalf("expandNamespace = %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("expandNamespace = %v, want %v", got, want) + } + } +}