s3tables: support multi-level namespace normalization

This commit is contained in:
Chris Lu
2026-02-09 19:42:31 -08:00
parent 0b80f055c2
commit be26ce74ce
10 changed files with 345 additions and 144 deletions

View File

@@ -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
}

View File

@@ -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,
})

View File

@@ -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
}

View File

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