snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

695
weed/s3api/AmazonS3.xsd Normal file
View File

@@ -0,0 +1,695 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema
xmlns:tns="http://s3.amazonaws.com/doc/2006-03-01/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
elementFormDefault="qualified"
targetNamespace="http://s3.amazonaws.com/doc/2006-03-01/">
<xsd:element name="CreateBucket">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="MetadataEntry">
<xsd:sequence>
<xsd:element name="Name" type="xsd:string"/>
<xsd:element name="Value" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="CreateBucketResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="CreateBucketReturn" type="tns:CreateBucketResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="Status">
<xsd:sequence>
<xsd:element name="Code" type="xsd:int"/>
<xsd:element name="Description" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="Result">
<xsd:sequence>
<xsd:element name="Status" type="tns:Status"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="CreateBucketResult">
<xsd:sequence>
<xsd:element name="BucketName" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="DeleteBucket">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="DeleteBucketResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="DeleteBucketResponse" type="tns:Status"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="BucketLoggingStatus">
<xsd:sequence>
<xsd:element name="LoggingEnabled" type="tns:LoggingSettings" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="LoggingSettings">
<xsd:sequence>
<xsd:element name="TargetBucket" type="xsd:string"/>
<xsd:element name="TargetPrefix" type="xsd:string"/>
<xsd:element name="TargetGrants" type="tns:AccessControlList" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="GetBucketLoggingStatus">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetBucketLoggingStatusResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="GetBucketLoggingStatusResponse" type="tns:BucketLoggingStatus"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="SetBucketLoggingStatus">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
<xsd:element name="BucketLoggingStatus" type="tns:BucketLoggingStatus"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="SetBucketLoggingStatusResponse">
<xsd:complexType>
<xsd:sequence/>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetObjectAccessControlPolicy">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetObjectAccessControlPolicyResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="GetObjectAccessControlPolicyResponse" type="tns:AccessControlPolicy"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetBucketAccessControlPolicy">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetBucketAccessControlPolicyResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="GetBucketAccessControlPolicyResponse" type="tns:AccessControlPolicy"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType abstract="true" name="Grantee"/>
<xsd:complexType name="User" abstract="true">
<xsd:complexContent>
<xsd:extension base="tns:Grantee"/>
</xsd:complexContent>
</xsd:complexType>
<xsd:complexType name="AmazonCustomerByEmail">
<xsd:complexContent>
<xsd:extension base="tns:User">
<xsd:sequence>
<xsd:element name="EmailAddress" type="xsd:string"/>
</xsd:sequence>
</xsd:extension>
</xsd:complexContent>
</xsd:complexType>
<xsd:complexType name="CanonicalUser">
<xsd:complexContent>
<xsd:extension base="tns:User">
<xsd:sequence>
<xsd:element name="ID" type="xsd:string"/>
<xsd:element name="DisplayName" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:extension>
</xsd:complexContent>
</xsd:complexType>
<xsd:complexType name="Group">
<xsd:complexContent>
<xsd:extension base="tns:Grantee">
<xsd:sequence>
<xsd:element name="URI" type="xsd:string"/>
</xsd:sequence>
</xsd:extension>
</xsd:complexContent>
</xsd:complexType>
<xsd:simpleType name="Permission">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="READ"/>
<xsd:enumeration value="WRITE"/>
<xsd:enumeration value="READ_ACP"/>
<xsd:enumeration value="WRITE_ACP"/>
<xsd:enumeration value="FULL_CONTROL"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="StorageClass">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="STANDARD"/>
<xsd:enumeration value="REDUCED_REDUNDANCY"/>
<xsd:enumeration value="GLACIER"/>
<xsd:enumeration value="UNKNOWN"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:complexType name="Grant">
<xsd:sequence>
<xsd:element name="Grantee" type="tns:Grantee"/>
<xsd:element name="Permission" type="tns:Permission"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="AccessControlList">
<xsd:sequence>
<xsd:element name="Grant" type="tns:Grant" minOccurs="0" maxOccurs="100"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="CreateBucketConfiguration">
<xsd:sequence>
<xsd:element name="LocationConstraint" type="tns:LocationConstraint"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="LocationConstraint">
<xsd:simpleContent>
<xsd:extension base="xsd:string"/>
</xsd:simpleContent>
</xsd:complexType>
<xsd:complexType name="AccessControlPolicy">
<xsd:sequence>
<xsd:element name="Owner" type="tns:CanonicalUser"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="SetObjectAccessControlPolicy">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="SetObjectAccessControlPolicyResponse">
<xsd:complexType>
<xsd:sequence/>
</xsd:complexType>
</xsd:element>
<xsd:element name="SetBucketAccessControlPolicy">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="SetBucketAccessControlPolicyResponse">
<xsd:complexType>
<xsd:sequence/>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetObject">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="GetMetadata" type="xsd:boolean"/>
<xsd:element name="GetData" type="xsd:boolean"/>
<xsd:element name="InlineData" type="xsd:boolean"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetObjectResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="GetObjectResponse" type="tns:GetObjectResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="GetObjectResult">
<xsd:complexContent>
<xsd:extension base="tns:Result">
<xsd:sequence>
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="unbounded"/>
<xsd:element name="Data" type="xsd:base64Binary" nillable="true"/>
<xsd:element name="LastModified" type="xsd:dateTime"/>
<xsd:element name="ETag" type="xsd:string"/>
</xsd:sequence>
</xsd:extension>
</xsd:complexContent>
</xsd:complexType>
<xsd:element name="GetObjectExtended">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="GetMetadata" type="xsd:boolean"/>
<xsd:element name="GetData" type="xsd:boolean"/>
<xsd:element name="InlineData" type="xsd:boolean"/>
<xsd:element name="ByteRangeStart" type="xsd:long" minOccurs="0"/>
<xsd:element name="ByteRangeEnd" type="xsd:long" minOccurs="0"/>
<xsd:element name="IfModifiedSince" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="IfUnmodifiedSince" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="IfMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
<xsd:element name="IfNoneMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
<xsd:element name="ReturnCompleteObjectOnConditionFailure" type="xsd:boolean" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="GetObjectExtendedResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="GetObjectResponse" type="tns:GetObjectResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="PutObject">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="100"/>
<xsd:element name="ContentLength" type="xsd:long"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
<xsd:element name="StorageClass" type="tns:StorageClass" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="PutObjectResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="PutObjectResponse" type="tns:PutObjectResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="PutObjectResult">
<xsd:sequence>
<xsd:element name="ETag" type="xsd:string"/>
<xsd:element name="LastModified" type="xsd:dateTime"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="PutObjectInline">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element minOccurs="0" maxOccurs="100" name="Metadata" type="tns:MetadataEntry"/>
<xsd:element name="Data" type="xsd:base64Binary"/>
<xsd:element name="ContentLength" type="xsd:long"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
<xsd:element name="StorageClass" type="tns:StorageClass" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="PutObjectInlineResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="PutObjectInlineResponse" type="tns:PutObjectResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="DeleteObject">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="DeleteObjectResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="DeleteObjectResponse" type="tns:Status"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="ListBucket">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Prefix" type="xsd:string" minOccurs="0"/>
<xsd:element name="Marker" type="xsd:string" minOccurs="0"/>
<xsd:element name="MaxKeys" type="xsd:int" minOccurs="0"/>
<xsd:element name="Delimiter" type="xsd:string" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="ListBucketResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="ListBucketResponse" type="tns:ListBucketResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="ListVersionsResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="ListVersionsResponse" type="tns:ListVersionsResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="ListEntry">
<xsd:sequence>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="LastModified" type="xsd:dateTime"/>
<xsd:element name="ETag" type="xsd:string"/>
<xsd:element name="Size" type="xsd:long"/>
<xsd:element name="Owner" type="tns:CanonicalUser" minOccurs="0"/>
<xsd:element name="StorageClass" type="tns:StorageClass"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="VersionEntry">
<xsd:sequence>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="VersionId" type="xsd:string"/>
<xsd:element name="IsLatest" type="xsd:boolean"/>
<xsd:element name="LastModified" type="xsd:dateTime"/>
<xsd:element name="ETag" type="xsd:string"/>
<xsd:element name="Size" type="xsd:long"/>
<xsd:element name="Owner" type="tns:CanonicalUser" minOccurs="0"/>
<xsd:element name="StorageClass" type="tns:StorageClass"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="DeleteMarkerEntry">
<xsd:sequence>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="VersionId" type="xsd:string"/>
<xsd:element name="IsLatest" type="xsd:boolean"/>
<xsd:element name="LastModified" type="xsd:dateTime"/>
<xsd:element name="Owner" type="tns:CanonicalUser" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="PrefixEntry">
<xsd:sequence>
<xsd:element name="Prefix" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="ListBucketResult">
<xsd:sequence>
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="unbounded"/>
<xsd:element name="Name" type="xsd:string"/>
<xsd:element name="Prefix" type="xsd:string"/>
<xsd:element name="Marker" type="xsd:string"/>
<xsd:element name="NextMarker" type="xsd:string" minOccurs="0"/>
<xsd:element name="MaxKeys" type="xsd:int"/>
<xsd:element name="Delimiter" type="xsd:string" minOccurs="0"/>
<xsd:element name="IsTruncated" type="xsd:boolean"/>
<xsd:element name="Contents" type="tns:ListEntry" minOccurs="0" maxOccurs="unbounded"/>
<xsd:element name="CommonPrefixes" type="tns:PrefixEntry" minOccurs="0" maxOccurs="unbounded"/>
<xsd:element name="EncodingType" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="ListVersionsResult">
<xsd:sequence>
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="unbounded"/>
<xsd:element name="Name" type="xsd:string"/>
<xsd:element name="Prefix" type="xsd:string"/>
<xsd:element name="KeyMarker" type="xsd:string"/>
<xsd:element name="VersionIdMarker" type="xsd:string"/>
<xsd:element name="NextKeyMarker" type="xsd:string" minOccurs="0"/>
<xsd:element name="NextVersionIdMarker" type="xsd:string" minOccurs="0"/>
<xsd:element name="MaxKeys" type="xsd:int"/>
<xsd:element name="Delimiter" type="xsd:string" minOccurs="0"/>
<xsd:element name="IsTruncated" type="xsd:boolean"/>
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<xsd:element name="Version" type="tns:VersionEntry"/>
<xsd:element name="DeleteMarker" type="tns:DeleteMarkerEntry"/>
</xsd:choice>
<xsd:element name="CommonPrefixes" type="tns:PrefixEntry" minOccurs="0" maxOccurs="unbounded"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="ListAllMyBuckets">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="ListAllMyBucketsResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="ListAllMyBucketsResponse" type="tns:ListAllMyBucketsResult"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="ListAllMyBucketsEntry">
<xsd:sequence>
<xsd:element name="Name" type="xsd:string"/>
<xsd:element name="CreationDate" type="xsd:dateTime"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="ListAllMyBucketsResult">
<xsd:sequence>
<xsd:element name="Owner" type="tns:CanonicalUser"/>
<xsd:element name="Buckets" type="tns:ListAllMyBucketsList"/>
<xsd:element name="ContinuationToken" type="xsd:string" minOccurs="0"/>
<xsd:element name="Prefix" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="ListAllMyBucketsList">
<xsd:sequence>
<xsd:element name="Bucket" type="tns:ListAllMyBucketsEntry" minOccurs="0" maxOccurs="unbounded"/>
</xsd:sequence>
</xsd:complexType>
<xsd:element name="PostResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="Location" type="xsd:anyURI"/>
<xsd:element name="Bucket" type="xsd:string"/>
<xsd:element name="Key" type="xsd:string"/>
<xsd:element name="ETag" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:simpleType name="MetadataDirective">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="COPY"/>
<xsd:enumeration value="REPLACE"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:element name="CopyObject">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="SourceBucket" type="xsd:string"/>
<xsd:element name="SourceKey" type="xsd:string"/>
<xsd:element name="DestinationBucket" type="xsd:string"/>
<xsd:element name="DestinationKey" type="xsd:string"/>
<xsd:element name="MetadataDirective" type="tns:MetadataDirective" minOccurs="0"/>
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="100"/>
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
<xsd:element name="CopySourceIfModifiedSince" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="CopySourceIfUnmodifiedSince" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="CopySourceIfMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
<xsd:element name="CopySourceIfNoneMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
<xsd:element name="StorageClass" type="tns:StorageClass" minOccurs="0"/>
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:element name="CopyObjectResponse">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="CopyObjectResult" type="tns:CopyObjectResult" />
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="CopyObjectResult">
<xsd:sequence>
<xsd:element name="LastModified" type="xsd:dateTime"/>
<xsd:element name="ETag" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="RequestPaymentConfiguration">
<xsd:sequence>
<xsd:element name="Payer" type="tns:Payer" minOccurs="1" maxOccurs="1"/>
</xsd:sequence>
</xsd:complexType>
<xsd:simpleType name="Payer">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="BucketOwner"/>
<xsd:enumeration value="Requester"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:complexType name="VersioningConfiguration">
<xsd:sequence>
<xsd:element name="Status" type="tns:VersioningStatus" minOccurs="0"/>
<xsd:element name="MfaDelete" type="tns:MfaDeleteStatus" minOccurs="0"/>
</xsd:sequence>
</xsd:complexType>
<xsd:simpleType name="MfaDeleteStatus">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="Enabled"/>
<xsd:enumeration value="Disabled"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="VersioningStatus">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="Enabled"/>
<xsd:enumeration value="Suspended"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:complexType name="NotificationConfiguration">
<xsd:sequence>
<xsd:element name="TopicConfiguration" minOccurs="0" maxOccurs="unbounded" type="tns:TopicConfiguration"/>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="TopicConfiguration">
<xsd:sequence>
<xsd:element name="Topic" minOccurs="1" maxOccurs="1" type="xsd:string"/>
<xsd:element name="Event" minOccurs="1" maxOccurs="unbounded" type="xsd:string"/>
</xsd:sequence>
</xsd:complexType>
</xsd:schema>

7
weed/s3api/README.txt Normal file
View File

@@ -0,0 +1,7 @@
see https://blog.aqwari.net/xml-schema-go/
1. go get aqwari.net/xml/cmd/xsdgen
2. Add EncodingType element for ListBucketResult in AmazonS3.xsd
3. xsdgen -o s3api_xsd_generated.go -pkg s3api AmazonS3.xsd
4. Remove empty Grantee struct in s3api_xsd_generated.go
5. Remove xmlns: sed s'/http:\/\/s3.amazonaws.com\/doc\/2006-03-01\/\ //' s3api_xsd_generated.go

View File

@@ -0,0 +1,292 @@
package s3api
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAccountForUnscopedIdentity(t *testing.T) {
assert.Equal(t, "alice", accountForUnscopedIdentity("alice").Id, "a named identity gets its own account id")
assert.NotEqual(t, AccountAdmin.Id, accountForUnscopedIdentity("alice").Id, "a named identity must not inherit the admin account")
assert.Same(t, &AccountAdmin, accountForUnscopedIdentity(AccountAdmin.Id), "the conventional admin keeps the admin account")
assert.Same(t, &AccountAdmin, accountForUnscopedIdentity(""), "an empty name falls back to the admin account")
}
func TestUnscopedIdentitiesGetDistinctAccounts(t *testing.T) {
resetMemoryStore()
config := `{
"identities": [
{"name": "alice", "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read"]},
{"name": "admin", "credentials": [{"accessKey": "admin_ak", "secretKey": "admin_sk"}], "actions": ["Admin"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
alice, _, found := iam.LookupByAccessKey("alice_ak")
require.True(t, found)
require.NotNil(t, alice.Account)
assert.Equal(t, "alice", alice.Account.Id, "a non-admin account-less identity owns resources as itself, not as admin")
admin, _, found := iam.LookupByAccessKey("admin_ak")
require.True(t, found)
require.NotNil(t, admin.Account)
assert.Equal(t, AccountAdmin.Id, admin.Account.Id, "the admin identity keeps the admin account")
}
// A distinct non-owner is denied an admin-owned bucket (iam nil => isUserAdmin
// false, so only real ownership grants access).
func TestCheckAccessByOwnershipDeniesNonOwner(t *testing.T) {
adminOwner := AccountAdmin.Id
s3a := &S3ApiServer{
bucketRegistry: NewBucketRegistry(nil),
}
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: "b", Owner: &s3.Owner{ID: &adminOwner}})
nonOwner := httptest.NewRequest(http.MethodGet, "/b?ownershipControls=", nil)
nonOwner.Header.Set(s3_constants.AmzAccountId, "alice")
assert.Equal(t, s3err.ErrAccessDenied, s3a.checkAccessByOwnership(nonOwner, "b"), "a distinct non-owner is denied the admin-owned bucket")
owner := httptest.NewRequest(http.MethodGet, "/b?ownershipControls=", nil)
owner.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
assert.Equal(t, s3err.ErrNone, s3a.checkAccessByOwnership(owner, "b"), "the actual owner is still allowed")
}
// An account-less identity's synthesized account must be registered in the
// account lookup so its id resolves to a display name. Otherwise ACL grantee
// validation and owner display report the id as "not exists" — the regression
// where a canned PutObjectAcl granting to the caller's own account returned
// 400 InvalidRequest.
func TestUnscopedIdentityAccountResolvesByName(t *testing.T) {
resetMemoryStore()
config := `{
"identities": [
{"name": "alice", "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read", "Write"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
assert.Equal(t, "alice", iam.GetAccountNameById("alice"),
"account-less identity id must resolve to a display name for ACL/owner validation")
}
// When an account is explicitly configured with the same id an account-less
// identity would synthesize, the identity must reuse that configured account so
// its custom display name/email are preserved.
func TestUnscopedIdentityReusesConfiguredAccount(t *testing.T) {
resetMemoryStore()
config := `{
"accounts": [
{"id": "alice", "displayName": "Alice Smith", "emailAddress": "alice@example.com"}
],
"identities": [
{"name": "alice", "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
assert.Equal(t, "Alice Smith", iam.GetAccountNameById("alice"),
"explicitly configured account display name must be preserved")
alice, _, found := iam.LookupByAccessKey("alice_ak")
require.True(t, found)
require.NotNil(t, alice.Account)
assert.Equal(t, "Alice Smith", alice.Account.DisplayName,
"identity must reuse the configured account, not the synthesized one")
}
// Users created through the IAM API carry their account inline on the identity,
// and no credential store emits a top-level accounts list. Such an account must
// be registered rather than collapsed into the admin account, which gave every
// one of those users the same owner id for ownership checks.
func TestInlineIdentityAccountIsRegistered(t *testing.T) {
resetMemoryStore()
config := `{
"identities": [
{"name": "alice", "account": {"id": "100000000001", "displayName": "Alice", "emailAddress": "alice@example.com"}, "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read", "Write"]},
{"name": "bob", "account": {"id": "100000000002", "displayName": "Bob", "emailAddress": "bob@example.com"}, "credentials": [{"accessKey": "bob_ak", "secretKey": "bob_sk"}], "actions": ["Read", "Write"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
alice, _, found := iam.LookupByAccessKey("alice_ak")
require.True(t, found)
require.NotNil(t, alice.Account)
assert.Equal(t, "100000000001", alice.Account.Id, "an undeclared inline account must not collapse into admin")
bob, _, found := iam.LookupByAccessKey("bob_ak")
require.True(t, found)
require.NotNil(t, bob.Account)
assert.NotEqual(t, alice.Account.Id, bob.Account.Id, "distinct users must not share an owner id")
assert.Equal(t, "Alice", iam.GetAccountNameById("100000000001"), "the registered account must resolve for ACL/owner display")
assert.Equal(t, "100000000002", iam.GetAccountIdByEmail("bob@example.com"), "the registered account must resolve by email")
}
// A dynamic update carries a single identity with no accounts list, so the merge
// path must register the inline account the same way the full load does.
func TestInlineIdentityAccountIsRegisteredOnUpsert(t *testing.T) {
resetMemoryStore()
config := `{
"identities": [
{"name": "admin", "credentials": [{"accessKey": "admin_ak", "secretKey": "admin_sk"}], "actions": ["Admin"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
Name: "alice",
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice", EmailAddress: "alice@example.com"},
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
Actions: []string{"Read", "Write"},
}))
alice, _, found := iam.LookupByAccessKey("alice_ak")
require.True(t, found)
require.NotNil(t, alice.Account)
assert.Equal(t, "100000000001", alice.Account.Id, "a pushed identity must keep its own account id")
assert.NotEqual(t, AccountAdmin.Id, alice.Account.Id, "a pushed identity must not inherit the admin account")
assert.Equal(t, "Alice", iam.GetAccountNameById("100000000001"), "the registered account must resolve for ACL/owner display")
// Changing the email keeps the account id, so the merge starts from a cached
// account that still carries the old address.
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
Name: "alice",
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice Smith", EmailAddress: "alice.smith@example.com"},
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
Actions: []string{"Read", "Write"},
}))
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("alice.smith@example.com"), "the new email must be indexed")
assert.Empty(t, iam.GetAccountIdByEmail("alice@example.com"), "the replaced email must no longer resolve")
assert.Equal(t, "Alice Smith", iam.GetAccountNameById("100000000001"), "the display name must follow the update")
}
// Two inline accounts can carry the same email. The first to register keeps the
// lookup, and the loser can claim it once the holder moves away — otherwise an
// email freed by an update would resolve to nobody.
func TestInlineAccountEmailClaimAndHandoff(t *testing.T) {
resetMemoryStore()
config := `{
"identities": [
{"name": "admin", "credentials": [{"accessKey": "admin_ak", "secretKey": "admin_sk"}], "actions": ["Admin"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
Name: "alice",
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice", EmailAddress: "shared@example.com"},
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
Actions: []string{"Read"},
}))
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
Name: "bob",
Account: &iam_pb.Account{Id: "100000000002", DisplayName: "Bob", EmailAddress: "shared@example.com"},
Credentials: []*iam_pb.Credential{{AccessKey: "bob_ak", SecretKey: "bob_sk"}},
Actions: []string{"Read"},
}))
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("shared@example.com"), "the first account to claim an email keeps it")
// alice moves off the shared address, freeing it
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
Name: "alice",
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice", EmailAddress: "alice@example.com"},
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
Actions: []string{"Read"},
}))
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("alice@example.com"), "the moved account indexes its new email")
// bob re-syncs unchanged and picks up the address he never got to claim
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
Name: "bob",
Account: &iam_pb.Account{Id: "100000000002", DisplayName: "Bob", EmailAddress: "shared@example.com"},
Credentials: []*iam_pb.Credential{{AccessKey: "bob_ak", SecretKey: "bob_sk"}},
Actions: []string{"Read"},
}))
assert.Equal(t, "100000000002", iam.GetAccountIdByEmail("shared@example.com"), "a freed email resolves to the account still holding it")
}
// An account declared in a top-level accounts list outranks an identity's inline
// block, which must not rewrite its metadata or take over its email.
func TestDeclaredAccountOutranksInlineIdentityAccount(t *testing.T) {
resetMemoryStore()
config := `{
"accounts": [
{"id": "100000000001", "displayName": "Alice Smith", "emailAddress": "alice@example.com"}
],
"identities": [
{"name": "alice", "account": {"id": "100000000001", "displayName": "wrong", "emailAddress": "wrong@example.com"}, "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read"]}
]
}`
tmp, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmp.Name())
_, err = tmp.WriteString(config)
require.NoError(t, err)
require.NoError(t, tmp.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
assert.Equal(t, "Alice Smith", iam.GetAccountNameById("100000000001"), "the declared display name must win")
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("alice@example.com"), "the declared email must stay indexed")
assert.Empty(t, iam.GetAccountIdByEmail("wrong@example.com"), "an inline block must not claim an email for a declared account")
}

View File

@@ -0,0 +1,217 @@
package s3api
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// An STS session authenticates as an opaque session subject, so the audit entry
// must also carry the principal ARN — the role name and the role session name
// are only recoverable from there.
func TestAuditRequesterArnForSTSSession(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: "ClientRole/dev-session",
Principal: "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session",
Subject: "47ad4828c45b3f337bc3146081ba8f0f",
SessionName: "dev-session",
Credentials: &sts.Credentials{
AccessKeyId: "ASIA0189777d42cba8e2",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"ClientPolicy"},
}, nil
},
},
}
// track() installs the holder before authentication runs.
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
require.Equal(t, s3err.ErrNone, errCode)
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
assert.Equal(t, "47ad4828c45b3f337bc3146081ba8f0f", log.Requester)
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session", log.RequesterArn,
"audit entry must name the assumed role and session")
}
// An OIDC-federated STS session authenticates as an opaque session subject, so
// the requester field alone is useless for compliance auditing. The audit entry
// must surface the authoritative OIDC identity claim (preferred_username, email
// or sub) carried in the session request context, independent of the
// client-supplied role session name. See issue #11264.
func TestAuditRequesterIdentityForOIDCFederatedSession(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: "S3ReadOnlyRole/boto3-session",
Principal: "arn:aws:sts::assumed-role/S3ReadOnlyRole/boto3-session",
Subject: "2a19e647c5d43a62a91ecf664d07dbe1",
SessionName: "boto3-session",
Credentials: &sts.Credentials{
AccessKeyId: "ASIA0189777d42cba8e2",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"S3ReadOnly"},
ParentUser: sts.ComputeParentUser("oidc-sub-123", "https://idp.example/"),
RequestContext: map[string]interface{}{
"preferred_username": "grant.west",
"email": "grant.west@digital.mod.uk",
"sub": "oidc-sub-123",
},
}, nil
},
},
}
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
require.Equal(t, s3err.ErrNone, errCode)
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
assert.Equal(t, "2a19e647c5d43a62a91ecf664d07dbe1", log.Requester,
"requester stays the opaque session subject for backward compatibility")
assert.Equal(t, "grant.west", log.RequesterIdentity,
"audit entry must surface the authoritative OIDC identity claim, not the client-supplied role session name")
}
// A non-federated STS session has no OIDC identity. ValidateJWTWithClaims merges
// the JWT registered sub claim (the opaque session id) into RequestContext, so
// the context carries a sub even though it is not an OIDC subject. The audit
// entry must not surface that session id as a requester_identity — ParentUser
// gates the resolution so only federated sessions report an identity claim.
func TestAuditRequesterIdentityEmptyForNonFederatedSession(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: "ClientRole/dev-session",
Principal: "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session",
Subject: "47ad4828c45b3f337bc3146081ba8f0f",
SessionName: "dev-session",
Credentials: &sts.Credentials{
AccessKeyId: "ASIA0189777d42cba8e2",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"ClientPolicy"},
// Simulate ValidateJWTWithClaims merging the registered sub
// (the session id) into RequestContext for a session that has
// no OIDC identity and therefore no ParentUser.
RequestContext: map[string]interface{}{
"sub": "47ad4828c45b3f337bc3146081ba8f0f",
},
}, nil
},
},
}
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
require.Equal(t, s3err.ErrNone, errCode)
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
assert.Empty(t, log.RequesterIdentity, "non-federated session must not surface the session id as a requester_identity")
}
// A JWT-authenticated identity carries no PrincipalArn of its own — the auth
// layer hands the principal over in a request header — so the audit entry has to
// resolve the ARN the same way policy evaluation does.
func TestAuditRequesterArnForJWTIdentity(t *testing.T) {
iam := &IdentityAccessManagement{}
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
outer.Header.Set(s3_constants.SeaweedFSPrincipalHeader, "arn:aws:sts::000000000000:assumed-role/ClientRole/oidc-session")
identity := &Identity{Name: "alice", Account: &Account{Id: "alice"}}
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
assert.Equal(t, "alice", log.Requester)
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/oidc-session", log.RequesterArn)
}
// The AssumeRole call itself is authenticated inside the STS handler, which the
// generic auth middleware never wraps; without recording the caller there the
// audit entry for minting a session has no requester at all.
func TestAuditRequesterForAssumeRole(t *testing.T) {
ctx := context.Background()
manager := newTestSTSIntegrationManager(t)
require.NoError(t, manager.CreatePolicy(ctx, "", "ClientPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
}},
}))
require.NoError(t, manager.CreateRole(ctx, "", "ClientRole", &integration.RoleDefinition{
RoleName: "ClientRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}},
},
AttachedPolicies: []string{"ClientPolicy"},
}))
const accessKey, secretKey = "adminkey", "adminsecret"
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "admin",
Credentials: []*iam_pb.Credential{{AccessKey: accessKey, SecretKey: secretKey}},
Actions: []string{"Admin"},
}},
}))
body := url.Values{
"Action": {"AssumeRole"},
"Version": {"2011-06-15"},
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/ClientRole"},
"RoleSessionName": {"dev-session"},
}.Encode()
req, err := newTestRequest(http.MethodPost, "http://sts.seaweedfs.test/", int64(len(body)), strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
require.NoError(t, signRequestV4(req, accessKey, secretKey))
req = s3_constants.EnsureIdentityHolder(req)
rec := httptest.NewRecorder()
NewSTSHandlers(manager.GetSTSService(), iam).handleAssumeRole(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
log := s3err.GetAccessLog(req, rec.Code, s3err.ErrNone)
assert.Equal(t, "admin", log.Requester, "AssumeRole must audit who asked for the session")
assert.Equal(t, "arn:aws:iam::"+defaultAccountID+":user/admin", log.RequesterArn)
}

View File

@@ -0,0 +1,68 @@
package s3api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
)
// A config file the proto parser reads as empty - an unpopulated secret mount,
// a singular "identity" - used to leave the gateway serving every anonymous
// request, including ListBuckets and bucket creation.
func TestConfigWithoutIdentitiesDeniesAnonymous(t *testing.T) {
for name, config := range map[string]string{
"no identities key": `{}`,
"empty": `{"identities":[]}`,
"unrecognised key": `{"identity":[{"name":"admin","credentials":[{"accessKey":"adminkey","secretKey":"adminsecret"}],"actions":["Admin"]}]}`,
} {
t.Run(name, func(t *testing.T) {
resetMemoryStore()
clearEnvironmentVariableCredentials(t)
path := writeTempIamConfig(t, config)
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: path}, nil, "memory")
assert.True(t, iam.isEnabled(), "naming a config file asks for authentication, even if it yields no identity")
handlerCalled := false
handler := iam.Auth(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
}, s3_constants.ACTION_LIST)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
assert.False(t, handlerCalled, "ListBuckets must not run for an anonymous caller")
assert.Equal(t, http.StatusForbidden, recorder.Code)
})
}
}
// `weed mini` and `docker run seaweedfs` name no config file and stay open.
func TestNoConfigKeepsAnonymousAllowed(t *testing.T) {
resetMemoryStore()
clearEnvironmentVariableCredentials(t)
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
assert.False(t, iam.isEnabled(), "auth must stay off when no config file and no identities are configured")
}
// AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY register an admin identity of
// their own, which would decide isAuthEnabled before the config file does.
func clearEnvironmentVariableCredentials(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "")
t.Setenv("AWS_SECRET_ACCESS_KEY", "")
}
// The proto parser drops what it does not recognise, so a typo has to be named
// at startup or the resulting lockout has no visible cause.
func TestUnknownS3ConfigKeys(t *testing.T) {
assert.Equal(t, []string{"identity"}, unknownS3ConfigKeys([]byte(`{"identity":[],"accounts":[]}`)))
assert.Empty(t, unknownS3ConfigKeys([]byte(`{"identities":[],"service_accounts":[],"serviceAccounts":[],"policies":[],"groups":[]}`)))
assert.Empty(t, unknownS3ConfigKeys([]byte(`{"kms":{},"sts":{},"policy":{},"providers":[],"roles":[]}`)), "sections owned by other subsystems are not typos")
assert.Empty(t, unknownS3ConfigKeys([]byte(`not json`)))
}

View File

@@ -0,0 +1,304 @@
package s3api
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// newCopyRequest builds a request that mimics what a CopyObject caller sends:
// a PUT to the destination bucket/object with X-Amz-Copy-Source pointing at the
// source. The destination is what the Auth middleware would have authorized.
func newCopyRequest(t *testing.T, dstBucket, dstObject, copySource string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodPut, "http://s3.local/"+dstBucket+"/"+dstObject, nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Copy-Source", copySource)
return req
}
// TestAuthorizeCopySource_AuthDisabled verifies the source check is a no-op when
// the IAM is not enabled (no identities configured).
func TestAuthorizeCopySource_AuthDisabled(t *testing.T) {
iam := &IdentityAccessManagement{}
require.False(t, iam.isEnabled())
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
errCode := iam.AuthorizeCopySource(req, nil, "src-bucket", "src-obj", "")
assert.Equal(t, s3err.ErrNone, errCode)
}
// TestAuthorizeCopySource_NilIdentityDenies verifies that with auth enabled we
// fail closed if no identity is available.
func TestAuthorizeCopySource_NilIdentityDenies(t *testing.T) {
iam := &IdentityAccessManagement{}
iam.identities = []*Identity{{Name: "anyone"}}
iam.isAuthEnabled = true
require.True(t, iam.isEnabled())
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
errCode := iam.AuthorizeCopySource(req, nil, "src-bucket", "src-obj", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
}
// TestAuthorizeCopySource_AdminAllowed verifies admin identities bypass the
// source check.
func TestAuthorizeCopySource_AdminAllowed(t *testing.T) {
iam := &IdentityAccessManagement{}
iam.identities = []*Identity{{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}}
iam.isAuthEnabled = true
admin := &Identity{Name: "admin", Account: &AccountAdmin, Actions: []Action{s3_constants.ACTION_ADMIN}}
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
errCode := iam.AuthorizeCopySource(req, admin, "src-bucket", "src-obj", "")
assert.Equal(t, s3err.ErrNone, errCode)
}
// TestAuthorizeCopySource_PrefixScopedIdentity checks that an identity scoped
// to prefix-a/* is denied when copying from prefix-b/*, even when its
// destination (prefix-a/*) write permission is fine.
func TestAuthorizeCopySource_PrefixScopedIdentity(t *testing.T) {
iam := &IdentityAccessManagement{}
iam.isAuthEnabled = true
scoped := &Identity{
Name: "scoped",
Account: &AccountAdmin,
Actions: []Action{
Action("Read:bucket-a/prefix-a/*"),
Action("Write:bucket-a/prefix-a/*"),
},
}
iam.identities = []*Identity{scoped}
t.Run("source within scope is allowed", func(t *testing.T) {
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-a/src.bin")
errCode := iam.AuthorizeCopySource(req, scoped, "bucket-a", "prefix-a/src.bin", "")
assert.Equal(t, s3err.ErrNone, errCode)
})
t.Run("source outside scope is denied", func(t *testing.T) {
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-b/src.bin")
errCode := iam.AuthorizeCopySource(req, scoped, "bucket-a", "prefix-b/src.bin", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
})
t.Run("source in different bucket is denied", func(t *testing.T) {
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/other-bucket/src.bin")
errCode := iam.AuthorizeCopySource(req, scoped, "other-bucket", "src.bin", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
})
}
// TestAuthorizeCopySource_IAMIntegrationGetsSourceResource verifies that the
// STS / IAM path is invoked with the SOURCE bucket/object and a GetObject-style
// action — not the destination from the request URL.
func TestAuthorizeCopySource_IAMIntegrationGetsSourceResource(t *testing.T) {
var capturedAction Action
var capturedBucket, capturedObject string
var capturedMethod string
var capturedURLPath string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
// Resolve the action like S3IAMIntegration.AuthorizeAction would,
// so the assertion reflects the s3:* the policy engine sees.
capturedAction = Action(ResolveS3Action(r, string(action), bucket, object))
capturedBucket = bucket
capturedObject = object
if r != nil {
capturedMethod = r.Method
if r.URL != nil {
capturedURLPath = r.URL.Path
}
}
// Deny: simulate a session policy that does not grant Read on src.
return s3err.ErrAccessDenied
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.identities = []*Identity{{Name: "sts"}}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/scopedRole/session",
Account: &AccountAdmin,
Actions: []Action{}, // STS identity
PrincipalArn: "arn:aws:sts::assumed-role/scopedRole/session",
}
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-b/src.bin")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "prefix-b/src.bin", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
assert.True(t, mock.authCalled, "IAM integration must be invoked for STS source check")
// Action passed through ResolveS3Action: GET method + ACTION_READ -> s3:GetObject.
assert.Equal(t, "s3:GetObject", string(capturedAction))
assert.Equal(t, "bucket-a", capturedBucket)
assert.Equal(t, "prefix-b/src.bin", capturedObject)
assert.Equal(t, http.MethodGet, capturedMethod, "synthetic source request must be a GET")
assert.Equal(t, "/bucket-a/prefix-b/src.bin", capturedURLPath, "synthetic URL must target the source")
// Original request must not be mutated by the source check.
assert.Equal(t, http.MethodPut, req.Method)
assert.Equal(t, "/bucket-a/prefix-a/dst.bin", req.URL.Path)
}
// TestAuthorizeCopySource_IAMIntegrationAllow verifies the IAM integration can
// grant access; the source check then returns ErrNone.
func TestAuthorizeCopySource_IAMIntegrationAllow(t *testing.T) {
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.identities = []*Identity{{Name: "sts"}}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-a/src.bin")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "prefix-a/src.bin", "")
assert.Equal(t, s3err.ErrNone, errCode)
}
// TestAuthorizeCopySource_VersionIdPropagated verifies that when copying a
// specific source version, the IAM check sees a versionId on the synthetic
// request so that s3:VersionId conditions and s3:GetObjectVersion resolution
// work.
func TestAuthorizeCopySource_VersionIdPropagated(t *testing.T) {
var capturedAction Action
var capturedRawQuery string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
capturedAction = Action(ResolveS3Action(r, string(action), bucket, object))
if r != nil && r.URL != nil {
capturedRawQuery = r.URL.RawQuery
}
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
req := newCopyRequest(t, "bucket-a", "dst.bin", "/bucket-a/src.bin?versionId=abc123")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "abc123")
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "s3:GetObjectVersion", string(capturedAction), "versioned source should resolve to GetObjectVersion")
assert.Equal(t, "versionId=abc123", capturedRawQuery)
}
// TestAuthorizeCopySource_PresignedURLSessionTokenPreserved guards against a
// regression where the synthetic source request dropped X-Amz-Security-Token
// from the original query string. Presigned URLs carry the STS token there
// (not in headers), and VerifyActionPermission/authorizeWithIAM use it to
// route the request to IAM authorization.
func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
var capturedSessionToken string
var capturedRawQuery string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
if r != nil {
capturedSessionToken = r.URL.Query().Get("X-Amz-Security-Token")
capturedRawQuery = r.URL.RawQuery
}
// The IAM mock receives the propagated session token via IAMIdentity too.
if identity != nil && identity.SessionToken != "" {
capturedSessionToken = identity.SessionToken
}
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
// Presigned URL: session token is in the query string, no
// X-Amz-Security-Token header. The destination URL also carries unrelated
// SigV4 query params (X-Amz-Algorithm, etc.) that must not leak into the
// synthetic source action resolution.
req, err := http.NewRequest(http.MethodPut,
"http://s3.local/bucket-a/dst.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256"+
"&X-Amz-Credential=stsKey%2F20260518%2Fus-east-1%2Fs3%2Faws4_request"+
"&X-Amz-Date=20260518T000000Z&X-Amz-Expires=900"+
"&X-Amz-Security-Token=presigned-session-token"+
"&X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef", nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Copy-Source", "/bucket-a/src.bin")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "")
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "presigned-session-token", capturedSessionToken,
"session token from presigned-URL query must reach the IAM check")
// versionId omitted, only the token should be present in the synthetic query.
assert.Equal(t, "X-Amz-Security-Token=presigned-session-token", capturedRawQuery)
}
// TestAuthorizeCopySource_PreservesCopySourceHeader verifies that the synthetic
// source request still carries the X-Amz-Copy-Source header so policy condition
// keys like s3:x-amz-copy-source remain available.
func TestAuthorizeCopySource_PreservesCopySourceHeader(t *testing.T) {
var capturedCopySource string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
if r != nil {
capturedCopySource = r.Header.Get("X-Amz-Copy-Source")
}
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
req := newCopyRequest(t, "bucket-a", "dst.bin", "/bucket-a/src.bin")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
_ = iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "")
assert.Equal(t, "/bucket-a/src.bin", capturedCopySource)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
package s3api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newNativeFloorTestIAM(t *testing.T) (*IdentityAccessManagement, *Identity) {
t.Helper()
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
doc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*"},
},
})
require.NoError(t, iam.PutPolicy("ListBucketsOnly", string(doc)))
identity := &Identity{
Name: "admin",
Account: &Account{DisplayName: "admin", Id: "admin"},
Actions: []Action{s3_constants.ACTION_ADMIN},
PolicyNames: []string{"ListBucketsOnly"},
PrincipalArn: "arn:aws:iam::111122223333:user/admin",
}
return iam, identity
}
func putObjectRequest() *http.Request {
return httptest.NewRequest(http.MethodPut, "/mybucket/file.txt", nil)
}
// TestNativeAdminSurvivesAttachedPolicy reproduces issue #11226: attaching an
// IAM policy to a user with native Admin must not override the native grant.
// The effective permissions are the union of native permissions and attached
// policy grants, so a Write the attached policy never mentions still succeeds.
func TestNativeAdminSurvivesAttachedPolicy(t *testing.T) {
iam, identity := newNativeFloorTestIAM(t)
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
assert.Equal(t, s3err.ErrNone, errCode,
"native Admin must remain effective after attaching a policy")
}
// TestNativeAdminSurvivesDeletedPolicy reproduces the second half of #11226:
// deleting the attached policy without detaching it first must not leave the
// user locked out of their native permissions.
func TestNativeAdminSurvivesDeletedPolicy(t *testing.T) {
iam, identity := newNativeFloorTestIAM(t)
require.NoError(t, iam.DeletePolicy("ListBucketsOnly"))
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
assert.Equal(t, s3err.ErrNone, errCode,
"native Admin must remain effective after the attached policy is deleted")
}
// TestNativeAdminSurvivesAttachedPolicyWithoutPrincipalArn covers the IAM
// integration path when the Admin identity has no PrincipalArn (and no session
// token), so the auth-path switch would otherwise deny before the native floor.
func TestNativeAdminSurvivesAttachedPolicyWithoutPrincipalArn(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
doc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*"},
},
})
require.NoError(t, iam.PutPolicy("ListBucketsOnly", string(doc)))
identity := &Identity{
Name: "admin",
Account: &Account{DisplayName: "admin", Id: "admin"},
Actions: []Action{s3_constants.ACTION_ADMIN},
PolicyNames: []string{"ListBucketsOnly"},
}
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
assert.Equal(t, s3err.ErrNone, errCode,
"native Admin must remain effective without a PrincipalArn")
}
// TestAttachedPolicyExplicitDenyOverridesNativeAdmin ensures deny-always-wins:
// an explicit Deny in an attached policy still constrains a native admin.
func TestAttachedPolicyExplicitDenyOverridesNativeAdmin(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
doc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Deny", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::mybucket/*"},
},
})
require.NoError(t, iam.PutPolicy("DenyPutMyBucket", string(doc)))
identity := &Identity{
Name: "admin",
Account: &Account{DisplayName: "admin", Id: "admin"},
Actions: []Action{s3_constants.ACTION_ADMIN},
PolicyNames: []string{"DenyPutMyBucket"},
PrincipalArn: "arn:aws:iam::111122223333:user/admin",
}
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
assert.Equal(t, s3err.ErrAccessDenied, errCode,
"explicit Deny in an attached policy must override native Admin")
}

View File

@@ -0,0 +1,84 @@
package s3api
import (
"encoding/json"
"strings"
)
// normalizeAdvancedIAMPolicies rewrites policies written in the advanced IAM
// form ({"name": ..., "document": {...}}) into the S3 config form
// ({"name": ..., "content": "{...}"}).
//
// The advanced IAM file given by -s3.iam.config is also parsed as the S3
// identity config when no -s3.config is given, and protojson drops the unknown
// "document" field. That leaves a policy with empty content, which fails every
// later parse and takes the whole runtime policy sync down with it.
func normalizeAdvancedIAMPolicies(configContent []byte) []byte {
var root map[string]json.RawMessage
if err := json.Unmarshal(configContent, &root); err != nil {
return configContent
}
rawPolicies, found := root["policies"]
if !found {
return configContent
}
var policies []map[string]json.RawMessage
if err := json.Unmarshal(rawPolicies, &policies); err != nil {
return configContent
}
rewritten := false
for _, policy := range policies {
document, hasDocument := policy["document"]
if !hasDocument || hasPolicyContent(policy) {
continue
}
// A document already written as a JSON string is the content verbatim;
// an inline object becomes the JSON encoding of its own bytes. Nothing
// is mutated until the content is in hand, so a failure leaves the
// policy as it was rather than stripping its only definition.
content := document
if !isJSONString(document) {
encoded, err := json.Marshal(string(document))
if err != nil {
continue
}
content = encoded
}
delete(policy, "document")
policy["content"] = content
rewritten = true
}
if !rewritten {
return configContent
}
encodedPolicies, err := json.Marshal(policies)
if err != nil {
return configContent
}
root["policies"] = encodedPolicies
normalized, err := json.Marshal(root)
if err != nil {
return configContent
}
return normalized
}
func hasPolicyContent(policy map[string]json.RawMessage) bool {
raw, found := policy["content"]
if !found {
return false
}
var content string
if err := json.Unmarshal(raw, &content); err != nil {
// Not a string: leave whatever it is for the proto parser to reject.
return true
}
return strings.TrimSpace(content) != ""
}
func isJSONString(raw json.RawMessage) bool {
var s string
return json.Unmarshal(raw, &s) == nil
}

View File

@@ -0,0 +1,83 @@
package s3api
import (
"encoding/json"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/stretchr/testify/require"
)
// The advanced IAM file doubles as the S3 identity config when only
// -s3.iam.config is given, so its "document" policies must survive the parse.
func TestLoadAdvancedIAMConfigPolicyDocument(t *testing.T) {
config := []byte(`{
"sts": {"issuer": "seaweedfs-sts"},
"policies": [
{
"name": "ClientPolicy",
"document": {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": ["s3:*"], "Resource": ["*"]}]
}
}
]
}`)
parsed := &iam_pb.S3ApiConfiguration{}
require.NoError(t, filer.ParseS3ConfigurationFromBytes(normalizeAdvancedIAMPolicies(config), parsed))
require.Len(t, parsed.Policies, 1)
require.Equal(t, "ClientPolicy", parsed.Policies[0].Name)
var document map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(parsed.Policies[0].Content), &document))
require.Equal(t, "2012-10-17", document["Version"])
}
func TestNormalizeAdvancedIAMPolicies(t *testing.T) {
for _, tc := range []struct {
name string
config string
content string
}{
{
name: "document object",
config: `{"policies":[{"name":"p","document":{"Version":"2012-10-17"}}]}`,
content: `{"Version":"2012-10-17"}`,
},
{
name: "document already encoded as a string",
config: `{"policies":[{"name":"p","document":"{\"Version\":\"2012-10-17\"}"}]}`,
content: `{"Version":"2012-10-17"}`,
},
{
name: "content wins over document",
config: `{"policies":[{"name":"p","content":"{\"Version\":\"keep\"}","document":{"Version":"drop"}}]}`,
content: `{"Version":"keep"}`,
},
{
name: "content only",
config: `{"policies":[{"name":"p","content":"{\"Version\":\"keep\"}"}]}`,
content: `{"Version":"keep"}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
parsed := &iam_pb.S3ApiConfiguration{}
require.NoError(t, filer.ParseS3ConfigurationFromBytes(normalizeAdvancedIAMPolicies([]byte(tc.config)), parsed))
require.Len(t, parsed.Policies, 1)
require.JSONEq(t, tc.content, parsed.Policies[0].Content)
})
}
}
// Anything that is not a policy list is handed to the proto parser untouched.
func TestNormalizeAdvancedIAMPoliciesLeavesOtherConfigsAlone(t *testing.T) {
for _, config := range []string{
`not json`,
`{"identities":[{"name":"admin"}]}`,
`{"policies":{"p":{"document":{}}}}`,
} {
require.Equal(t, config, string(normalizeAdvancedIAMPolicies([]byte(config))))
}
}

View File

@@ -0,0 +1,223 @@
package s3api
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/stretchr/testify/require"
)
func newTestIAMManager(t *testing.T) *integration.IAMManager {
t.Helper()
mgr := integration.NewIAMManager()
require.NoError(t, mgr.Initialize(&integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test",
SigningKey: []byte("test-signing-key-32-characters!!"),
AccountId: "111122223333",
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Allow", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}, func() string { return "localhost:8888" }))
return mgr
}
func TestPutPolicy_SyncsToIAMManager(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
policyDoc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::backup/*"},
},
})
require.NoError(t, iam.PutPolicy("BackupAll", string(policyDoc)))
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
Principal: "arn:aws:iam::111122223333:user/test",
Action: "s3:PutObject",
Resource: "arn:aws:s3:::backup/file.txt",
PolicyNames: []string{"BackupAll"},
})
require.NoError(t, err)
require.True(t, allowed, "PutPolicy should sync to IAM Manager policy engine")
}
func TestDeletePolicy_SyncsToIAMManager(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
policyDoc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::backup/*"},
},
})
require.NoError(t, iam.PutPolicy("BackupAll", string(policyDoc)))
require.NoError(t, iam.DeletePolicy("BackupAll"))
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
Principal: "arn:aws:iam::111122223333:user/test",
Action: "s3:PutObject",
Resource: "arn:aws:s3:::backup/file.txt",
PolicyNames: []string{"BackupAll"},
})
require.NoError(t, err)
require.False(t, allowed, "DeletePolicy should remove from IAM Manager policy engine")
}
// TestSetIAMIntegration_FlushesLoadedPolicies reproduces the startup race: a
// policy is loaded into the IAM cache before the integration is attached (so the
// sync was skipped), then SetIAMIntegration must flush it into the manager's
// engine. Without the flush, policy_names identities get AccessDenied until an
// external IAM change triggers a reload.
func TestSetIAMIntegration_FlushesLoadedPolicies(t *testing.T) {
iam := &IdentityAccessManagement{}
policyDoc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::backup/*"},
},
})
// Integration not attached yet, so this only lands in the legacy engine.
require.NoError(t, iam.PutPolicy("BackupAll", string(policyDoc)))
mgr := newTestIAMManager(t)
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
Principal: "arn:aws:iam::111122223333:user/test",
Action: "s3:PutObject",
Resource: "arn:aws:s3:::backup/file.txt",
PolicyNames: []string{"BackupAll"},
})
require.NoError(t, err)
require.True(t, allowed, "SetIAMIntegration should flush already-loaded policies into the IAM Manager")
}
// TestResyncIAMManager_ReflectsCurrentPolicies pins the property that prevents
// the stale-snapshot race: SyncRuntimePolicies replaces the full desired set, so
// the resync must derive that set from the live iam.policies map at apply time,
// not from a view captured earlier. Here the map is mutated out from under an
// already-synced policy, and the next resync must converge the manager onto the
// new map rather than reinstating the old state.
func TestResyncIAMManager_ReflectsCurrentPolicies(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
doc := func(name string) string {
b, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::" + name + "/*"},
},
})
return string(b)
}
allows := func(name string) bool {
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
Principal: "arn:aws:iam::111122223333:user/test",
Action: "s3:PutObject",
Resource: "arn:aws:s3:::" + name + "/file.txt",
PolicyNames: []string{name},
})
require.NoError(t, err)
return allowed
}
require.NoError(t, iam.PutPolicy("alpha", doc("alpha")))
require.True(t, allows("alpha"))
// Swap the map contents without going through PutPolicy/DeletePolicy, then
// resync. A snapshot-based sync would push a pre-swap view; the fresh-read
// resync must mirror the current map: alpha gone, beta present.
iam.m.Lock()
delete(iam.policies, "alpha")
iam.policies["beta"] = &iam_pb.Policy{Name: "beta", Content: doc("beta")}
iam.m.Unlock()
iam.resyncIAMManagerPolicies()
require.False(t, allows("alpha"), "resync should drop a policy no longer in the map")
require.True(t, allows("beta"), "resync should add a policy newly in the map")
}
func TestResync_SkipsUnparsablePolicy(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
doc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::good/*"},
},
})
iam.m.Lock()
iam.policies = map[string]*iam_pb.Policy{
"broken": {Name: "broken"},
"good": {Name: "good", Content: string(doc)},
}
iam.m.Unlock()
iam.resyncIAMManagerPolicies()
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
Principal: "arn:aws:iam::111122223333:user/test",
Action: "s3:PutObject",
Resource: "arn:aws:s3:::good/file.txt",
PolicyNames: []string{"good"},
})
require.NoError(t, err)
require.True(t, allowed, "an unparsable policy must not block the rest of the sync")
}
// A policy whose stored definition stops parsing must stop granting access:
// enforcing a document the operator can no longer see is worse than denying.
func TestResync_UnparsablePolicyStopsGranting(t *testing.T) {
mgr := newTestIAMManager(t)
iam := &IdentityAccessManagement{}
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
doc, _ := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::good/*"},
},
})
allows := func() bool {
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
Principal: "arn:aws:iam::111122223333:user/test",
Action: "s3:PutObject",
Resource: "arn:aws:s3:::good/file.txt",
PolicyNames: []string{"good"},
})
require.NoError(t, err)
return allowed
}
require.NoError(t, iam.PutPolicy("good", string(doc)))
require.True(t, allows())
iam.m.Lock()
iam.policies["good"] = &iam_pb.Policy{Name: "good", Content: "{not json"}
iam.m.Unlock()
iam.resyncIAMManagerPolicies()
require.False(t, allows(), "an unparsable policy must not keep granting its old permissions")
}

View File

@@ -0,0 +1,473 @@
package s3api
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// An advanced -iam.config file (STS/OIDC/roles) carries no inline identities, so the
// server must not enter static-config mode. Otherwise it freezes live reloads and
// filer-backed identities created at runtime (e.g. by the operator's IAM CRDs) never
// take effect.
func TestIamConfigWithoutIdentitiesIsNotStatic(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"sts":{"signingKey":"dGVzdC1zaWduaW5nLWtleQ=="}}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load advanced iam config: %v", err)
}
if s3a.iam.IsStaticConfig() {
t.Fatalf("advanced iam config without identities must not be treated as static")
}
// A filer change (operator creating a user) must still reload at runtime.
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("failed to create alice: %v", err)
}
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", nil, &filer_pb.Entry{Name: "alice.json"}); err != nil {
t.Fatalf("onIamConfigChange returned error: %v", err)
}
waitForIdentity(t, s3a.iam, "alice")
}
// A -config identity file protects its identities but must not block live
// delivery of filer-managed identities to a running gateway.
func TestConfigWithIdentitiesStillLiveReloadsDynamic(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKIAITEST","secretKey":"c2VjcmV0"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
if !s3a.iam.IsStaticConfig() {
t.Fatalf("config file with inline identities must be treated as static")
}
s3a.iam.m.RLock()
id := s3a.iam.nameToIdentity["static-admin"]
s3a.iam.m.RUnlock()
if id == nil || !id.IsStatic {
t.Fatalf("expected static-admin to be marked static")
}
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("failed to create alice: %v", err)
}
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", nil, &filer_pb.Entry{Name: "alice.json"}); err != nil {
t.Fatalf("onIamConfigChange returned error: %v", err)
}
waitForIdentity(t, s3a.iam, "alice")
if !hasIdentity(s3a.iam, "static-admin") {
t.Fatalf("static-admin must survive the dynamic reload")
}
// deletion on the filer must revoke on the running gateway too
if err := s3a.iam.credentialManager.DeleteUser(context.Background(), "alice"); err != nil {
t.Fatalf("failed to delete alice: %v", err)
}
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", &filer_pb.Entry{Name: "alice.json"}, nil); err != nil {
t.Fatalf("onIamConfigChange returned error: %v", err)
}
waitForIdentityGone(t, s3a.iam, "alice")
if !hasIdentity(s3a.iam, "static-admin") {
t.Fatalf("static-admin must survive the deletion reload")
}
}
// A single pushed identity (PutIdentity) is a partial merge and must not wipe
// other dynamic identities or a dynamic anonymous identity.
func TestUpsertIdentityKeepsOtherDynamicIdentities(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKIAITEST","secretKey":"c2VjcmV0"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
for _, name := range []string{"alice", "anonymous"} {
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: name}); err != nil {
t.Fatalf("failed to create %s: %v", name, err)
}
}
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
t.Fatalf("failed to load from credential manager: %v", err)
}
if err := s3a.iam.UpsertIdentity(&iam_pb.Identity{Name: "bob", Actions: []string{"Read"}}); err != nil {
t.Fatalf("failed to upsert bob: %v", err)
}
for _, name := range []string{"static-admin", "alice", "anonymous", "bob"} {
if !hasIdentity(s3a.iam, name) {
t.Fatalf("expected %s to survive a partial upsert", name)
}
}
}
// Reloading the static config file (grace.OnReload) must mark newly added
// identities as static so dynamic filer updates can't overwrite them, while
// leaving already-loaded dynamic (filer-managed) identities untouched.
func TestReloadStaticConfigMarksNewIdentitiesWithoutFreezingDynamic(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
p1 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"c2VjcmV0"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(p1); err != nil {
t.Fatalf("failed to load initial config: %v", err)
}
// A dynamic identity arrives from the filer; merge mode keeps it dynamic.
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("failed to create alice: %v", err)
}
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
t.Fatalf("failed to load from credential manager: %v", err)
}
if !hasIdentity(s3a.iam, "alice") {
t.Fatalf("expected alice to load dynamically")
}
// Reload the static file with a new identity bob.
p2 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"c2VjcmV0"}],"actions":["Admin"]},{"name":"bob","credentials":[{"accessKey":"AKBOB000","secretKey":"c2VjcmV0"}],"actions":["Read"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(p2); err != nil {
t.Fatalf("failed to reload config: %v", err)
}
if !isStaticName(s3a.iam, "bob") {
t.Fatalf("expected reloaded identity bob to be marked static")
}
if isStaticName(s3a.iam, "alice") {
t.Fatalf("dynamic identity alice must not be frozen as static by a config reload")
}
}
// A config-file reload must apply an edited secretKey to its static identity.
func TestReloadStaticConfigUpdatesExistingSecretKey(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
p1 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"b2xkc2VjcmV0"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(p1); err != nil {
t.Fatalf("failed to load initial config: %v", err)
}
_, cred, found := s3a.iam.lookupByAccessKey("AKADMIN0")
if !found || cred.SecretKey != "b2xkc2VjcmV0" {
t.Fatalf("expected initial secretKey to load, got found=%v cred=%+v", found, cred)
}
// Rotate the secretKey in the file and reload.
p2 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"bmV3c2VjcmV0"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(p2); err != nil {
t.Fatalf("failed to reload config: %v", err)
}
_, cred, found = s3a.iam.lookupByAccessKey("AKADMIN0")
if !found {
t.Fatalf("static-admin access key disappeared after reload")
}
if cred.SecretKey != "bmV3c2VjcmV0" {
t.Fatalf("expected reloaded secretKey bmV3c2VjcmV0, got %q", cred.SecretKey)
}
if !isStaticName(s3a.iam, "static-admin") {
t.Fatalf("static-admin must stay marked static after reload")
}
}
// A reload must also reapply a service-account credential under a static parent.
func TestReloadStaticConfigUpdatesServiceAccountSecret(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
p1 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"YWRtaW4="}],"actions":["Admin"]}],"serviceAccounts":[{"id":"sa-1","parentUser":"static-admin","credential":{"accessKey":"AKSA0001","secretKey":"b2xkc2E="}}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(p1); err != nil {
t.Fatalf("failed to load initial config: %v", err)
}
if _, cred, found := s3a.iam.lookupByAccessKey("AKSA0001"); !found || cred.SecretKey != "b2xkc2E=" {
t.Fatalf("expected service account secret to load, got found=%v cred=%+v", found, cred)
}
// Rotate the service account secret in the file and reload.
p2 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"YWRtaW4="}],"actions":["Admin"]}],"serviceAccounts":[{"id":"sa-1","parentUser":"static-admin","credential":{"accessKey":"AKSA0001","secretKey":"bmV3c2E="}}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(p2); err != nil {
t.Fatalf("failed to reload config: %v", err)
}
_, cred, found := s3a.iam.lookupByAccessKey("AKSA0001")
if !found {
t.Fatalf("service account access key disappeared after reload")
}
if cred.SecretKey != "bmV3c2E=" {
t.Fatalf("expected reloaded service account secret bmV3c2E=, got %q", cred.SecretKey)
}
}
// A full snapshot reconciles policy and group deletions, static-file policies
// survive, and groups in a static config file are ignored: the dynamic store
// is the only source of groups.
func TestFullStateMergeReconcilesPoliciesAndGroups(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKIAITEST","secretKey":"c2VjcmV0"}],"actions":["Admin"]}],"policies":[{"name":"file-policy","content":"{}"}],"groups":[{"name":"file-group","members":["static-admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
if hasGroup(s3a.iam, "file-group") {
t.Fatalf("groups in a static config file must be ignored")
}
full := &iam_pb.S3ApiConfiguration{
Policies: []*iam_pb.Policy{{Name: "dynamic-policy", Content: "{}"}},
Groups: []*iam_pb.Group{{Name: "g1"}},
}
if err := s3a.iam.MergeS3ApiConfiguration(full, false, true); err != nil {
t.Fatalf("full merge failed: %v", err)
}
if !hasPolicy(s3a.iam, "file-policy") || !hasPolicy(s3a.iam, "dynamic-policy") || !hasGroup(s3a.iam, "g1") {
t.Fatalf("expected file-policy, dynamic-policy and g1 after full merge")
}
// partial merge preserves groups and policies
if err := s3a.iam.UpsertIdentity(&iam_pb.Identity{Name: "bob"}); err != nil {
t.Fatalf("upsert failed: %v", err)
}
if !hasPolicy(s3a.iam, "dynamic-policy") || !hasGroup(s3a.iam, "g1") {
t.Fatalf("partial merge must not drop dynamic-policy or g1")
}
// a static-file reload leaves dynamic groups alone
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to reload config: %v", err)
}
if !hasGroup(s3a.iam, "g1") {
t.Fatalf("dynamic g1 must survive a static-file reload")
}
// empty full snapshot: dynamic policy and last group deleted, file policy stays
if err := s3a.iam.MergeS3ApiConfiguration(&iam_pb.S3ApiConfiguration{}, false, true); err != nil {
t.Fatalf("empty full merge failed: %v", err)
}
if hasPolicy(s3a.iam, "dynamic-policy") {
t.Fatalf("expected dynamic-policy to be removed by empty full snapshot")
}
if hasGroup(s3a.iam, "g1") {
t.Fatalf("expected g1 to be removed by empty full snapshot")
}
if !hasPolicy(s3a.iam, "file-policy") {
t.Fatalf("file-policy must survive full-state reconciliation")
}
}
// flakyStore fails LoadConfiguration a fixed number of times.
type flakyStore struct {
credential.CredentialStore
failures int
}
func (f *flakyStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
if f.failures > 0 {
f.failures--
return nil, fmt.Errorf("transient store failure")
}
return f.CredentialStore.LoadConfiguration(ctx)
}
// A failed reload queued from an IAM config change must keep retrying until
// the store recovers.
func TestFailedReloadRetriesUntilSuccess(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
prev := iamReloadRetryInterval
iamReloadRetryInterval = 10 * time.Millisecond
t.Cleanup(func() { iamReloadRetryInterval = prev })
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("failed to create alice: %v", err)
}
s3a.iam.credentialManager.Store = &flakyStore{CredentialStore: s3a.iam.credentialManager.Store, failures: 2}
// onIamConfigChange only queues a coalesced reload; the retry loop does the
// work and retries through the transient store failures.
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", nil, &filer_pb.Entry{Name: "alice.json"}); err != nil {
t.Fatalf("onIamConfigChange returned error: %v", err)
}
waitForIdentity(t, s3a.iam, "alice")
}
func hasPolicy(iam *IdentityAccessManagement, name string) bool {
iam.m.RLock()
defer iam.m.RUnlock()
_, ok := iam.policies[name]
return ok
}
func hasGroup(iam *IdentityAccessManagement, name string) bool {
iam.m.RLock()
defer iam.m.RUnlock()
_, ok := iam.groups[name]
return ok
}
func isStaticName(iam *IdentityAccessManagement, name string) bool {
iam.m.RLock()
defer iam.m.RUnlock()
return iam.staticIdentityNames[name]
}
func writeTempIamConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iam.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
return path
}
// A static config file may hold ${VAR} in place of a key, so a deployment can
// keep the keys in its own secret store and pass them in as environment
// variables rather than baking them into the file.
func TestStaticConfigExpandsEnvCredentialRefs(t *testing.T) {
t.Setenv("SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID", "AKIAFROMENV")
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "secretfromenv")
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
_, cred, found := s3a.iam.lookupByAccessKey("AKIAFROMENV")
if !found {
t.Fatalf("expected the access key from the environment to be loaded")
}
if cred.SecretKey != "secretfromenv" {
t.Fatalf("expected the secret key from the environment, got %q", cred.SecretKey)
}
if _, _, found := s3a.iam.lookupByAccessKey("${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}"); found {
t.Fatalf("the unexpanded reference must not remain usable as an access key")
}
}
// An unset variable must not leave the reference behind as a literal key.
func TestStaticConfigDropsUnresolvedEnvCredentialRefs(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_MISSING_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_MISSING_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
if _, _, found := s3a.iam.lookupByAccessKey("${SEAWEEDFS_S3_MISSING_ACCESS_KEY_ID}"); found {
t.Fatalf("a credential referencing an unset variable must be dropped")
}
if !hasIdentity(s3a.iam, "anvAdmin") {
t.Fatalf("expected the identity itself to still load")
}
}
// Keys that merely contain a dollar sign are literal, and identities coming
// from the filer are never expanded.
func TestEnvCredentialRefsOnlyApplyToStaticConfig(t *testing.T) {
t.Setenv("SEAWEEDFS_S3_DYNAMIC_SECRET_ACCESS_KEY", "secretfromenv")
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
if err := s3a.iam.LoadS3ApiConfigurationFromBytes([]byte(`{"identities":[{"name":"dynamic","credentials":[{"accessKey":"AKIALITERAL","secretKey":"${SEAWEEDFS_S3_DYNAMIC_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)); err != nil {
t.Fatalf("failed to load dynamic config: %v", err)
}
_, cred, found := s3a.iam.lookupByAccessKey("AKIALITERAL")
if !found {
t.Fatalf("expected the dynamic identity to load")
}
if cred.SecretKey != "${SEAWEEDFS_S3_DYNAMIC_SECRET_ACCESS_KEY}" {
t.Fatalf("a dynamic identity must keep its secret key verbatim, got %q", cred.SecretKey)
}
}
// A key holding a dollar sign that is not a reference stays untouched.
func TestStaticConfigKeepsLiteralDollarSigns(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"AKIALITERAL","secretKey":"pa$$word"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
_, cred, found := s3a.iam.lookupByAccessKey("AKIALITERAL")
if !found {
t.Fatalf("expected the identity to load")
}
if cred.SecretKey != "pa$$word" {
t.Fatalf("expected the literal secret key, got %q", cred.SecretKey)
}
}
// A secret store handing over a blank value must not leave an access key that
// any signature matches.
func TestStaticConfigDropsEmptyEnvCredentialRefs(t *testing.T) {
t.Setenv("SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID", "AKIAFROMENV")
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "")
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
if _, _, found := s3a.iam.lookupByAccessKey("AKIAFROMENV"); found {
t.Fatalf("a credential whose secret key resolves to empty must be dropped")
}
}
// A reference the substitution cannot match, such as a typo in the variable
// name, must not survive as a literal key.
func TestStaticConfigDropsMalformedEnvCredentialRefs(t *testing.T) {
for _, malformed := range []string{"${MY-VAR}", "${1VAR}", "${}", "${UNTERMINATED", "${A}${B"} {
t.Run(malformed, func(t *testing.T) {
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "secretfromenv")
t.Setenv("A", "a")
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, fmt.Sprintf(`{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":%q,"secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`, malformed))
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
if _, _, found := s3a.iam.lookupByAccessKey(malformed); found {
t.Fatalf("%s must not become a usable access key", malformed)
}
})
}
}
// A resolved value that happens to contain ${ is still the key the operator set.
func TestStaticConfigKeepsBracesComingFromTheEnvironment(t *testing.T) {
t.Setenv("SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID", "AKIAFROMENV")
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "pa${ss}word")
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
t.Fatalf("failed to load identity config: %v", err)
}
_, cred, found := s3a.iam.lookupByAccessKey("AKIAFROMENV")
if !found {
t.Fatalf("expected the identity to load")
}
if cred.SecretKey != "pa${ss}word" {
t.Fatalf("expected the secret key from the environment verbatim, got %q", cred.SecretKey)
}
}

View File

@@ -0,0 +1,227 @@
package s3api
import (
"context"
"strings"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
)
const oidcProvidersDir = filer.IamConfigDirectory + "/oidc-providers"
func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, prefix string, directoriesToWatch []string) {
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
message := resp.EventNotification
// For rename/move operations, NewParentPath contains the destination directory.
// We process both source and destination dirs so moves out of watched
// directories (e.g., IAM config dirs) are not missed.
dir := resp.Directory
if message.NewParentPath != "" {
dir = message.NewParentPath
}
// Handle all metadata changes (create, update, delete, rename)
// These handlers check for nil entries internally
_ = s3a.onBucketMetadataChange(dir, message.OldEntry, message.NewEntry)
_ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry)
_ = s3a.onOIDCProviderChange(dir, message.OldEntry, message.NewEntry)
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry)
// For moves across directories, replay a delete event for the source directory
if message.NewParentPath != "" && resp.Directory != message.NewParentPath {
_ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil)
_ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil)
_ = s3a.onOIDCProviderChange(resp.Directory, message.OldEntry, nil)
_ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil)
}
// For same-directory renames, replay a delete event for the old name
// so handlers can clean up stale state (e.g., old bucket names)
if message.OldEntry != nil && message.NewEntry != nil &&
(message.NewParentPath == "" || message.NewParentPath == resp.Directory) &&
message.OldEntry.Name != message.NewEntry.Name {
_ = s3a.onBucketMetadataChange(dir, message.OldEntry, nil)
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, nil)
}
return nil
}
metadataFollowOption := &pb.MetadataFollowOption{
ClientName: clientName,
ClientId: s3a.randomClientId,
ClientEpoch: 1,
SelfSignature: 0,
PathPrefix: prefix,
AdditionalPathPrefixes: nil,
DirectoriesToWatch: directoriesToWatch,
StartTsNs: lastTsNs,
StopTsNs: 0,
EventErrorType: pb.FatalOnError,
}
util.RetryUntil("followIamChanges", func() error {
metadataFollowOption.ClientEpoch++
return pb.WithFilerClientFollowMetadata(s3a, metadataFollowOption, processEventFn)
}, func(err error) bool {
glog.V(1).Infof("iam follow metadata changes: %v", err)
return true
})
}
// onIamConfigChange handles IAM config file changes (create, update, delete).
// It reloads even with a static -config file: the merge protects the file's
// identities, and the filer->s3 push alone is best-effort.
func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
if s3a.iam == nil {
return nil
}
// Coalesce bursts through the reload queue instead of one full synchronous
// reload per event: independently-refreshing credentials can rewrite several
// files within the same second.
// 1. Handle traditional single identity.json file
if dir == filer.IamConfigDirectory {
if (oldEntry != nil && oldEntry.Name == filer.IamIdentityFile) ||
(newEntry != nil && newEntry.Name == filer.IamIdentityFile) {
s3a.iam.scheduleReload(dir + "/" + filer.IamIdentityFile)
}
return nil
}
// 2. Handle multiple-file identities, policies, service accounts and groups
isIdentityDir := dir == filer.IamConfigDirectory+"/identities" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/identities/")
isPolicyDir := dir == filer.IamConfigDirectory+"/policies" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/policies/")
isServiceAccountDir := dir == filer.IamConfigDirectory+"/service_accounts" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/service_accounts/")
isGroupDir := dir == filer.IamConfigDirectory+"/groups" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/groups/")
if isIdentityDir || isPolicyDir || isServiceAccountDir || isGroupDir {
s3a.iam.scheduleReload(dir)
}
return nil
}
// onOIDCProviderChange refreshes the IAM-managed OIDC provider runtime view
// whenever the persisted store under /etc/iam/oidc-providers changes — both
// for mutations originated on this S3 server (the local IAM API also calls
// RefreshOIDCProvidersFromStore inline, but the subscribe path costs nothing
// extra) and for mutations originated on peer S3 servers, which this is the
// only mechanism to learn about. A single refresh covers create, update,
// delete, and rename because the store is small and a full reload is the
// safest way to reach a consistent view.
func (s3a *S3ApiServer) onOIDCProviderChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
if dir != oidcProvidersDir && !strings.HasPrefix(dir, oidcProvidersDir+"/") {
return nil
}
if s3a.iam == nil || s3a.iam.iamIntegration == nil {
return nil
}
s3iam, ok := s3a.iam.iamIntegration.(*S3IAMIntegration)
if !ok || s3iam.iamManager == nil {
return nil
}
if err := s3iam.iamManager.RefreshOIDCProvidersFromStore(context.Background()); err != nil {
glog.Warningf("OIDC provider refresh after %s change failed: %v", dir, err)
return err
}
glog.V(2).Infof("Refreshed IAM-managed OIDC providers after %s change", dir)
return nil
}
// onCircuitBreakerConfigChange handles circuit breaker config file changes (create, update, delete)
func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
if dir != s3_constants.CircuitBreakerConfigDir {
return nil
}
// Handle deletion: reset to empty config
if newEntry == nil && oldEntry != nil && oldEntry.Name == s3_constants.CircuitBreakerConfigFile {
glog.V(1).Infof("Circuit breaker config file deleted, resetting to defaults")
if err := s3a.cb.LoadS3ApiConfigurationFromBytes([]byte{}); err != nil {
glog.Warningf("failed to reset circuit breaker config on deletion: %v", err)
return err
}
return nil
}
// Handle create/update
if newEntry != nil && newEntry.Name == s3_constants.CircuitBreakerConfigFile {
if err := s3a.cb.LoadS3ApiConfigurationFromBytes(newEntry.Content); err != nil {
return err
}
glog.V(1).Infof("updated %s/%s", dir, newEntry.Name)
}
return nil
}
// reload bucket metadata
func (s3a *S3ApiServer) onBucketMetadataChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
if dir == s3a.option.BucketsPath {
s3a.maintainBucketOwnerIndex(oldEntry, newEntry)
s3a.mirrorBucketPolicyToIAM(oldEntry, newEntry)
if newEntry != nil {
// Update bucket registry (existing functionality)
s3a.bucketRegistry.LoadBucketMetadata(newEntry)
glog.V(1).Infof("updated bucketMetadata %s/%s", dir, newEntry.Name)
// Update bucket configuration cache with new entry
s3a.updateBucketConfigCacheFromEntry(newEntry)
} else if oldEntry != nil {
// Remove from bucket registry (existing functionality)
s3a.bucketRegistry.RemoveBucketMetadata(oldEntry)
glog.V(1).Infof("remove bucketMetadata %s/%s", dir, oldEntry.Name)
// Remove from bucket configuration cache
s3a.invalidateBucketConfigCache(oldEntry.Name)
}
}
return nil
}
// updateBucketConfigCacheFromEntry updates the bucket config cache when a bucket entry changes
func (s3a *S3ApiServer) updateBucketConfigCacheFromEntry(entry *filer_pb.Entry) {
if s3a.bucketConfigCache == nil {
return
}
bucket := entry.Name
// Remove from negative cache since bucket now exists
// This is important for buckets created via weed shell or other external means
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
// Only refresh buckets already resident in the cache; cold buckets
// lazy-load on first access so the cache holds this gateway's working
// set, not every bucket in the cluster.
if !s3a.bucketConfigCache.Contains(bucket) {
return
}
// newBucketConfigFromEntry is the single source of truth for mapping
// Entry.Extended → cached fields (incl. LifecycleTTL), so a meta-log
// Put/DeleteBucketLifecycle here can't leave a stale resolver in cache.
config := s3a.newBucketConfigFromEntry(bucket, entry)
glog.V(3).Infof("updateBucketConfigCacheFromEntry: refreshing cache for bucket %s, ObjectLockConfig=%+v", bucket, config.ObjectLockConfig)
s3a.bucketConfigCache.Set(bucket, config)
}
// invalidateBucketConfigCache removes a bucket from the configuration cache
func (s3a *S3ApiServer) invalidateBucketConfigCache(bucket string) {
if s3a.bucketConfigCache == nil {
return
}
s3a.bucketConfigCache.Remove(bucket)
s3a.bucketConfigCache.RemoveNegativeCache(bucket) // Also remove from negative cache
glog.V(2).Infof("invalidateBucketConfigCache: removed bucket %s from cache", bucket)
}

View File

@@ -0,0 +1,202 @@
package s3api
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func TestOnIamConfigChangeLegacyIdentityDeletionReloadsConfiguration(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{
{
Name: "anonymous",
Actions: []string{
"Read:test",
},
},
})
err := s3a.onIamConfigChange(
filer.IamConfigDirectory,
&filer_pb.Entry{Name: filer.IamIdentityFile},
nil,
)
if err != nil {
t.Fatalf("onIamConfigChange returned error for legacy identity deletion: %v", err)
}
if !hasIdentity(s3a.iam, "anonymous") {
t.Fatalf("expected anonymous identity to remain loaded after legacy identity deletion event")
}
}
func TestOnIamConfigChangeReloadsOnIamIdentityDirectoryChanges(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{
{Name: "anonymous"},
})
// Seed initial in-memory IAM state.
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
t.Fatalf("failed to load initial IAM configuration: %v", err)
}
if hasIdentity(s3a.iam, "alice") {
t.Fatalf("did not expect alice identity before creating user")
}
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("failed to create alice in memory credential manager: %v", err)
}
if err := s3a.onIamConfigChange(
filer.IamConfigDirectory+"/identities",
nil,
&filer_pb.Entry{Name: "alice.json"},
); err != nil {
t.Fatalf("onIamConfigChange returned error for identities directory update: %v", err)
}
waitForIdentity(t, s3a.iam, "alice")
}
func newTestS3ApiServerWithMemoryIAM(t *testing.T, identities []*iam_pb.Identity) *S3ApiServer {
t.Helper()
// Create S3ApiConfiguration for test with provided identities
config := &iam_pb.S3ApiConfiguration{
Identities: identities,
Accounts: []*iam_pb.Account{},
ServiceAccounts: []*iam_pb.ServiceAccount{},
}
// Create memory credential manager
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("failed to create memory credential manager: %v", err)
}
// Save test configuration
if err := cm.SaveConfiguration(context.Background(), config); err != nil {
t.Fatalf("failed to save test configuration: %v", err)
}
// Create a test IAM instance
iam := &IdentityAccessManagement{
m: sync.RWMutex{},
nameToIdentity: make(map[string]*Identity),
accessKeyIdent: make(map[string]*Identity),
identities: []*Identity{},
policies: make(map[string]*iam_pb.Policy),
accounts: make(map[string]*Account),
emailAccount: make(map[string]*Account),
hashes: make(map[string]*sync.Pool),
hashCounters: make(map[string]*int32),
isAuthEnabled: false,
stopChan: make(chan struct{}),
reloadCh: make(chan struct{}, 1),
useStaticConfig: false,
credentialManager: cm,
}
go iam.reloadRetryLoop()
t.Cleanup(iam.Shutdown)
// Load test configuration
if err := iam.ReplaceS3ApiConfiguration(config); err != nil {
t.Fatalf("failed to load test configuration: %v", err)
}
return &S3ApiServer{
iam: iam,
}
}
func hasIdentity(iam *IdentityAccessManagement, identityName string) bool {
iam.m.RLock()
defer iam.m.RUnlock()
_, ok := iam.nameToIdentity[identityName]
return ok
}
func waitForIdentity(t *testing.T, iam *IdentityAccessManagement, name string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !hasIdentity(iam, name) {
if time.Now().After(deadline) {
t.Fatalf("expected identity %s to be loaded", name)
}
time.Sleep(10 * time.Millisecond)
}
}
func waitForIdentityGone(t *testing.T, iam *IdentityAccessManagement, name string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for hasIdentity(iam, name) {
if time.Now().After(deadline) {
t.Fatalf("expected identity %s to be gone", name)
}
time.Sleep(10 * time.Millisecond)
}
}
// countingStore wraps a store and counts LoadConfiguration calls.
type countingStore struct {
credential.CredentialStore
loads int64
}
func (c *countingStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
atomic.AddInt64(&c.loads, 1)
return c.CredentialStore.LoadConfiguration(ctx)
}
// A burst of IAM config change events must coalesce into a handful of reloads,
// not one full reload per event.
func TestOnIamConfigChangeCoalescesBurstReloads(t *testing.T) {
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{{Name: "anonymous"}})
counter := &countingStore{CredentialStore: s3a.iam.credentialManager.Store}
s3a.iam.credentialManager.Store = counter
const burst = 50
for i := 0; i < burst; i++ {
if err := s3a.onIamConfigChange(
filer.IamConfigDirectory+"/identities",
nil,
&filer_pb.Entry{Name: fmt.Sprintf("u%d.json", i)},
); err != nil {
t.Fatalf("onIamConfigChange returned error: %v", err)
}
}
// Wait for the queue to drain: no pending signal.
deadline := time.Now().Add(5 * time.Second)
for {
s3a.iam.reloadMu.Lock()
empty := len(s3a.iam.reloadCh) == 0
s3a.iam.reloadMu.Unlock()
if empty {
break
}
if time.Now().After(deadline) {
t.Fatalf("reload queue did not drain")
}
time.Sleep(10 * time.Millisecond)
}
// Let any final coalesced reload finish.
time.Sleep(50 * time.Millisecond)
loads := atomic.LoadInt64(&counter.loads)
if loads > 3 {
t.Fatalf("expected a burst of %d events to coalesce into <=3 reloads, got %d", burst, loads)
}
}

View File

@@ -0,0 +1,15 @@
package s3api
import (
"context"
"fmt"
)
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
// Delegates to the IAM integration if available
func (iam *IdentityAccessManagement) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
if iam.iamIntegration != nil {
return iam.iamIntegration.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
}
return fmt.Errorf("IAM integration not available")
}

View File

@@ -0,0 +1,60 @@
package s3api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestJWTStreamingUnsignedAuth covers the auth half of the JWT + unsigned-streaming
// gap. A bearer-token client whose SDK appends a CRC32 trailer sends
// x-amz-content-sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER with no SigV4 signature,
// so the request is classified authTypeStreamingUnsigned. It must authenticate via
// the JWT integration rather than silently falling back to the anonymous identity.
func TestJWTStreamingUnsignedAuth(t *testing.T) {
resetMemoryStore()
defer resetMemoryStore()
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
iam.iamIntegration = &MockIAMIntegration{
authenticateJWTFunc: func(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) {
return &IAMIdentity{Name: "jwt-user", Account: &AccountAdmin}, s3err.ErrNone
},
}
iam.isAuthEnabled = true
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
r.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
r.Header.Set("Authorization", "Bearer test.jwt.token")
// Stays classified as unsigned-streaming so getRequestDataReader still decodes
// the chunked body...
assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r))
// ...but authentication resolves to the JWT identity, not anonymous.
identity, errCode := iam.AuthenticateRequest(r)
assert.Equal(t, s3err.ErrNone, errCode, "JWT unsigned-streaming PUT must authenticate via the IAM integration")
require.NotNil(t, identity)
assert.Equal(t, "jwt-user", identity.Name)
}
// TestJWTStreamingUnsignedChunkedReader covers the body-decode half of the gap.
// newChunkedReader must not try to verify a bearer token as a SigV4 seed signature
// for an unsigned-streaming upload. It used to compute the seed signature whenever
// any Authorization header was present, so a JWT request failed to decode at all.
func TestJWTStreamingUnsignedChunkedReader(t *testing.T) {
iam := setupIam()
req, err := NewRequestStreamingUnsignedPayloadTrailer(true)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer test.jwt.token")
runWithRequest(iam, req, t, strings.Repeat("a", 17408))
}

View File

@@ -0,0 +1,45 @@
package s3api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// A presigned URL that signs content-type carries
// X-Amz-SignedHeaders=content-type%3Bhost. Some clients and proxies decode
// the %3B into a raw ';', which RFC 3986 permits in a query string and AWS
// accepts, but Go's url.ParseQuery drops the whole pair — verification then
// fails with MissingFields. The router middleware re-encodes the ';' so the
// parameter survives.
func TestPresignedPutSignedContentTypeWithRawSemicolon(t *testing.T) {
iam := newTestIAM()
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key.png", 0, nil)
if err != nil {
t.Fatalf("newTestRequest: %v", err)
}
req.Header.Set("Content-Type", "image/png")
if err := preSignV4WithHeaders(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600, []string{"host", "content-type"}); err != nil {
t.Fatalf("preSignV4WithHeaders: %v", err)
}
req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, "%3B", ";")
if _, errCode := iam.reqSignatureV4Verify(req); errCode == s3err.ErrNone {
t.Fatal("raw-semicolon query unexpectedly verified without normalization")
}
errCode := s3err.ErrInternalError
handler := util_http.EscapeSemicolonsInQuery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, errCode = iam.reqSignatureV4Verify(r)
}))
handler.ServeHTTP(httptest.NewRecorder(), req)
if errCode != s3err.ErrNone {
t.Fatalf("expected ErrNone through semicolon-normalizing middleware, got %v", errCode)
}
}

View File

@@ -0,0 +1,242 @@
package s3api
import (
"context"
"crypto/sha256"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestReverseProxySignatureVerification is an integration test that exercises
// the full HTTP stack: real AWS SDK v4 signer -> real httputil.ReverseProxy ->
// real net/http server running IAM signature verification.
//
// This catches issues that unit tests miss: header normalization by net/http,
// proxy header injection, and real-world Host header handling.
func TestReverseProxySignatureVerification(t *testing.T) {
const (
accessKey = "AKIAIOSFODNN7EXAMPLE"
secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)
configJSON := `{
"identities": [
{
"name": "test_user",
"credentials": [
{
"accessKey": "` + accessKey + `",
"secretKey": "` + secretKey + `"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}`
tests := []struct {
name string
externalUrl string // s3.externalUrl config for the backend
clientScheme string // scheme the client uses for signing
clientHost string // host the client signs against
proxyForwardsHost bool // whether proxy sets X-Forwarded-Host
portlessForwardedHost bool // nginx-style $host / $server_port: hostname in X-Forwarded-Host, port in X-Forwarded-Port
expectSuccess bool
}{
{
name: "non-standard port, externalUrl matches proxy address",
externalUrl: "", // filled dynamically with proxy address
clientScheme: "http",
clientHost: "", // filled dynamically
proxyForwardsHost: true,
expectSuccess: true,
},
{
name: "externalUrl with non-standard port, client signs against external host",
externalUrl: "http://api.example.com:9000",
clientScheme: "http",
clientHost: "api.example.com:9000",
proxyForwardsHost: true,
expectSuccess: true,
},
{
name: "externalUrl with HTTPS default port stripped, client signs without port",
externalUrl: "https://api.example.com:443",
clientScheme: "https",
clientHost: "api.example.com",
proxyForwardsHost: true,
expectSuccess: true,
},
{
name: "externalUrl with HTTP default port stripped, client signs without port",
externalUrl: "http://api.example.com:80",
clientScheme: "http",
clientHost: "api.example.com",
proxyForwardsHost: true,
expectSuccess: true,
},
{
name: "proxy forwards X-Forwarded-Host correctly, no externalUrl needed",
externalUrl: "",
clientScheme: "http",
clientHost: "api.example.com:9000",
proxyForwardsHost: true,
expectSuccess: true,
},
{
name: "proxy without X-Forwarded-Host, no externalUrl: host mismatch",
externalUrl: "",
clientScheme: "http",
clientHost: "api.example.com:9000",
proxyForwardsHost: false,
expectSuccess: false,
},
{
// The backend Host (127.0.0.1:ephemeral) shares the forwarded hostname, so the
// backend port must not shadow X-Forwarded-Port.
name: "nginx $host/$server_port forwarding, backend on same hostname",
externalUrl: "",
clientScheme: "http",
clientHost: "127.0.0.1:9000",
proxyForwardsHost: true,
portlessForwardedHost: true,
expectSuccess: true,
},
{
name: "portless X-Forwarded-Host on default port, backend on same hostname",
externalUrl: "",
clientScheme: "http",
clientHost: "127.0.0.1",
proxyForwardsHost: true,
portlessForwardedHost: true,
expectSuccess: true,
},
{
name: "proxy without X-Forwarded-Host, externalUrl saves the day",
externalUrl: "http://api.example.com:9000",
clientScheme: "http",
clientHost: "api.example.com:9000",
proxyForwardsHost: false,
expectSuccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// --- Write config to temp file ---
tmpFile := t.TempDir() + "/s3.json"
require.NoError(t, os.WriteFile(tmpFile, []byte(configJSON), 0644))
// --- Set up backend ---
var iam *IdentityAccessManagement
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, errCode := iam.authRequest(r, "Read")
if errCode != 0 {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "error: %d", int(errCode))
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}))
defer backend.Close()
// --- Set up reverse proxy ---
backendURL, _ := url.Parse(backend.URL)
proxy := httputil.NewSingleHostReverseProxy(backendURL)
forwardsHost := tt.proxyForwardsHost
portlessForwardedHost := tt.portlessForwardedHost
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalHost := req.Host
originalScheme := req.URL.Scheme
if originalScheme == "" {
originalScheme = "http"
}
originalDirector(req)
// Simulate real proxy behavior: rewrite Host to backend address
// (nginx proxy_pass and Kong both do this by default)
req.Host = backendURL.Host
if forwardsHost {
forwardedHost := originalHost
if portlessForwardedHost {
if h, p, splitErr := net.SplitHostPort(originalHost); splitErr == nil {
forwardedHost = h
req.Header.Set("X-Forwarded-Port", p)
}
}
req.Header.Set("X-Forwarded-Host", forwardedHost)
req.Header.Set("X-Forwarded-Proto", originalScheme)
}
}
proxyServer := httptest.NewServer(proxy)
defer proxyServer.Close()
// --- Configure IAM ---
externalUrl := tt.externalUrl
clientHost := tt.clientHost
clientScheme := tt.clientScheme
if externalUrl == "" && clientHost == "" {
// Dynamic: use the proxy's actual address
proxyURL, _ := url.Parse(proxyServer.URL)
externalUrl = proxyServer.URL
clientHost = proxyURL.Host
clientScheme = proxyURL.Scheme
}
option := &S3ApiServerOption{
Config: tmpFile,
ExternalUrl: externalUrl,
}
iam = NewIdentityAccessManagementWithStore(option, nil, "memory")
require.True(t, iam.isEnabled())
// --- Sign the request using real AWS SDK v4 signer ---
clientURL := fmt.Sprintf("%s://%s/test-bucket/test-object", clientScheme, clientHost)
req, err := http.NewRequest(http.MethodGet, clientURL, nil)
require.NoError(t, err)
req.Host = clientHost
signer := v4.NewSigner()
payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{}))
err = signer.SignHTTP(
context.Background(),
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secretKey},
req, payloadHash, "s3", "us-east-1", time.Now(),
)
require.NoError(t, err)
// --- Send the signed request through the proxy ---
// Rewrite destination to the proxy, but keep signed headers and Host intact
proxyURL, _ := url.Parse(proxyServer.URL)
req.URL.Scheme = proxyURL.Scheme
req.URL.Host = proxyURL.Host
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if tt.expectSuccess {
assert.Equal(t, http.StatusOK, resp.StatusCode,
"Expected signature verification to succeed through reverse proxy")
} else {
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
"Expected signature verification to fail (host mismatch)")
}
})
}
}

View File

@@ -0,0 +1,26 @@
package s3api
import "testing"
func TestActionScopedToBucket(t *testing.T) {
cases := []struct {
action string
bucket string
want bool
}{
{"Read:bucket", "bucket", true},
{"Write:bucket/prefix", "bucket", true},
{"Write:bucket/prefix/*", "bucket", true},
{"Write:bucket/*", "bucket", true},
{"Read:other/prefix/*", "bucket", false},
{"Write:*", "bucket", false},
{"Write:buck*/x", "bucket", false},
{"Write:bucketother", "bucket", false},
{"Admin", "bucket", false},
}
for _, c := range cases {
if got := actionScopedToBucket(c.action, c.bucket); got != c.want {
t.Errorf("actionScopedToBucket(%q, %q) = %v, want %v", c.action, c.bucket, got, c.want)
}
}
}

View File

@@ -0,0 +1,559 @@
package s3api
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func signRawHTTPRequest(ctx context.Context, req *http.Request, accessKey, secretKey, region string) error {
creds := aws.Credentials{
AccessKeyID: accessKey,
SecretAccessKey: secretKey,
}
signer := v4.NewSigner()
payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{}))
return signer.SignHTTP(ctx, creds, req, payloadHash, "s3", region, time.Now())
}
func TestReproIssue7912(t *testing.T) {
// This test asserts behavior for a config with no anonymous identity; reset
// the shared in-memory store so a leaked anonymous identity from another test
// does not satisfy the unsigned-streaming auth path.
resetMemoryStore()
// Create a temporary s3.json
configContent := `{
"identities": [
{
"name": "xx",
"credentials": [
{
"accessKey": "xx_access_key",
"secretKey": "xx_secret_key"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
},
{
"name": "read_only_user",
"credentials": [
{
"accessKey": "readonly_access_key",
"secretKey": "readonly_secret_key"
}
],
"actions": ["Read", "List"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
assert.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
assert.NoError(t, err)
tmpFile.Close()
// Initialize Identities Access Management
option := &S3ApiServerOption{
Config: tmpFile.Name(),
}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
assert.True(t, iam.isEnabled(), "Auth should be enabled")
// Test case 1: Unknown access key should be rejected
t.Run("Unknown access key", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "unknown_key", "any_secret", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode, "Should be denied with unknown access key")
assert.Nil(t, identity)
})
t.Run("Positive test case: properly signed credentials", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "readonly_access_key", "readonly_secret_key", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity)
assert.Equal(t, "read_only_user", identity.Name)
})
t.Run("Nil identity tests for guards", func(t *testing.T) {
var nilIdentity *Identity
// Test isAdmin guard
assert.False(t, nilIdentity.isAdmin())
// Test CanDo guard
assert.False(t, nilIdentity.CanDo(s3_constants.ACTION_LIST, "bucket", "object"))
})
t.Run("AuthSignatureOnly path", func(t *testing.T) {
// Valid request
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "xx_access_key", "xx_secret_key", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.AuthSignatureOnly(r)
assert.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity)
assert.Equal(t, "xx", identity.Name)
// Invalid request (wrong signature)
r2 := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r2.Host = "localhost:8333"
err = signRawHTTPRequest(context.Background(), r2, "xx_access_key", "this_is_a_wrong_secret", "us-east-1")
require.NoError(t, err)
_, errCode2 := iam.AuthSignatureOnly(r2)
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode2)
// Verify fix: Streaming unsigned payload should be denied without auth header in AuthSignatureOnly
r3 := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r3.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
// No Authorization header
_, errCode3 := iam.AuthSignatureOnly(r3)
assert.Equal(t, s3err.ErrAccessDenied, errCode3, "AuthSignatureOnly should be denied with unsigned streaming if no auth header")
// Verify fix: client-supplied X-SeaweedFS-Principal / X-SeaweedFS-Session-Token
// headers must be stripped, otherwise a signed low-privilege caller could
// spoof the principal that downstream IAM authorization (e.g. S3Tables
// CreateTableBucket) evaluates against.
r4 := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r4.Host = "localhost:8333"
err = signRawHTTPRequest(context.Background(), r4, "readonly_access_key", "readonly_secret_key", "us-east-1")
require.NoError(t, err)
// Attacker injects these headers after signing; they are not part of
// SignedHeaders, so SigV4 verification still passes.
r4.Header.Set(s3_constants.SeaweedFSPrincipalHeader, "arn:aws:iam::123456789012:root")
r4.Header.Set(s3_constants.SeaweedFSSessionTokenHeader, "spoofed-session-token")
_, errCode4 := iam.AuthSignatureOnly(r4)
assert.Equal(t, s3err.ErrNone, errCode4)
assert.Empty(t, r4.Header.Get(s3_constants.SeaweedFSPrincipalHeader), "client-supplied X-SeaweedFS-Principal must be stripped")
assert.Empty(t, r4.Header.Get(s3_constants.SeaweedFSSessionTokenHeader), "client-supplied X-SeaweedFS-Session-Token must be stripped")
})
t.Run("Wrong secret key", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "readonly_access_key", "this_is_a_wrong_secret", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode, "Should NOT be allowed with wrong signature")
assert.Nil(t, identity)
})
t.Run("Anonymous request to protected bucket", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/somebucket/", nil)
// No Authorization header
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should be denied for anonymous")
assert.Nil(t, identity)
})
t.Run("Non-S3 request should be denied", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
// No headers at all
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrAccessDenied, errCode)
assert.Nil(t, identity)
})
t.Run("Any other credentials", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "some_other_key", "some_secret", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode, "Should NOT be allowed with ANY other credentials")
assert.Nil(t, identity)
})
t.Run("Streaming unsigned payload bypass attempt", func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
// No Authorization header
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should be denied with unsigned streaming if no auth header")
assert.Nil(t, identity)
})
}
// TestAnonymousStreamingUnsignedUpload is a regression test for issue #9725.
// Modern botocore/aiobotocore attaches a CRC32 trailer to plain PutObject calls,
// turning the payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous
// (anon=True) upload then carries that header but no Authorization, which used to
// be routed straight to SigV4 verification and rejected as AccessDenied. It must
// instead fall back to the configured anonymous identity, just like a plain
// anonymous PUT does.
func TestAnonymousStreamingUnsignedUpload(t *testing.T) {
// This test loads an anonymous identity into the shared in-memory credential
// store; reset before and after so it neither inherits nor leaks state.
resetMemoryStore()
defer resetMemoryStore()
configContent := `{
"identities": [
{
"name": "anonymous",
"actions": ["Read", "Write", "List"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
require.NoError(t, tmpFile.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmpFile.Name()}, nil, "memory")
require.True(t, iam.isEnabled(), "Auth should be enabled")
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
r.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
// No Authorization header: anonymous upload with a checksum trailer.
// The request must stay classified as unsigned-streaming so getRequestDataReader
// still decodes the chunked body; only the auth resolution falls back to anonymous.
assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r))
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
assert.Equal(t, s3err.ErrNone, errCode, "anonymous unsigned-streaming PUT should resolve to the anonymous identity")
require.NotNil(t, identity)
assert.Equal(t, s3_constants.AccountAnonymousId, identity.Name)
}
// TestExternalUrlSignatureVerification tests that S3 signature verification works
// correctly when s3.externalUrl is configured. It uses the real AWS SDK v2 signer
// to prove correctness against actual S3 clients behind a reverse proxy.
func TestExternalUrlSignatureVerification(t *testing.T) {
configContent := `{
"identities": [
{
"name": "test_user",
"credentials": [
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
tmpFile.Close()
accessKey := "AKIAIOSFODNN7EXAMPLE"
secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
tests := []struct {
name string
clientUrl string // URL the client signs against
backendHost string // Host header SeaweedFS sees (from proxy)
externalUrl string // s3.externalUrl config
expectSuccess bool
}{
{
name: "non-standard port with externalUrl",
clientUrl: "https://api.example.com:9000/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "https://api.example.com:9000",
expectSuccess: true,
},
{
name: "HTTPS default port 443 with externalUrl (port stripped by SDK and parseExternalUrlToHost)",
clientUrl: "https://api.example.com/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "https://api.example.com:443",
expectSuccess: true,
},
{
name: "HTTPS without explicit port with externalUrl",
clientUrl: "https://api.example.com/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "https://api.example.com",
expectSuccess: true,
},
{
name: "HTTP default port 80 with externalUrl",
clientUrl: "http://api.example.com/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "http://api.example.com:80",
expectSuccess: true,
},
{
name: "externalUrl set, in-cluster client signs the service host",
clientUrl: "http://seaweedfs-s3:8333/test-bucket/object",
backendHost: "seaweedfs-s3:8333",
externalUrl: "https://api.example.com",
expectSuccess: true,
},
{
name: "externalUrl set, external client signs a virtual-hosted bucket name",
clientUrl: "https://test-bucket.api.example.com/object",
backendHost: "test-bucket.api.example.com",
externalUrl: "https://api.example.com",
expectSuccess: true,
},
{
name: "without externalUrl, internal host causes mismatch",
clientUrl: "https://api.example.com:9000/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "",
expectSuccess: false,
},
{
name: "without externalUrl, matching host works",
clientUrl: "http://localhost:8333/test-bucket/object",
backendHost: "localhost:8333",
externalUrl: "",
expectSuccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create IAM with the externalUrl
option := &S3ApiServerOption{
Config: tmpFile.Name(),
ExternalUrl: tt.externalUrl,
}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
require.True(t, iam.isEnabled())
// Step 1: Sign a request targeting the client-facing URL using real AWS SDK signer
signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil)
require.NoError(t, err)
signReq.Host = signReq.URL.Host
err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1")
require.NoError(t, err)
// Step 2: Create a separate request as the proxy would deliver it
proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil)
proxyReq.Host = tt.backendHost
proxyReq.URL.Host = tt.backendHost
// Copy the auth headers from the signed request
proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization"))
proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date"))
proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256"))
// Step 3: Verify
identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST)
if tt.expectSuccess {
assert.Equal(t, s3err.ErrNone, errCode, "Expected successful signature verification")
require.NotNil(t, identity)
assert.Equal(t, "test_user", identity.Name)
} else {
assert.NotEqual(t, s3err.ErrNone, errCode, "Expected signature verification to fail")
}
})
}
}
// TestRealSDKSignerWithForwardedHeaders proves that extractHostHeader produces
// values that match the real AWS SDK v2 signer's SanitizeHostForHeader behavior.
//
// Unlike the self-referential tests in auto_signature_v4_test.go (which sign and
// verify with the same extractHostHeader function), this test uses the real AWS
// SDK v2 signer to sign the request. The SDK has its own host sanitization:
// - strips :80 for http
// - strips :443 for https
// - preserves all other ports
//
// If extractHostHeader disagrees with the SDK, the signature will not match.
func TestRealSDKSignerWithForwardedHeaders(t *testing.T) {
configContent := `{
"identities": [
{
"name": "test_user",
"credentials": [
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
tmpFile.Close()
accessKey := "AKIAIOSFODNN7EXAMPLE"
secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
// Each test case simulates:
// 1. A client connecting to clientUrl (SDK signs with the host from this URL)
// 2. A proxy forwarding to SeaweedFS with X-Forwarded-* headers
// 3. SeaweedFS extracting the host and verifying the signature
tests := []struct {
name string
clientUrl string // URL the client/SDK signs against
backendHost string // Host header SeaweedFS receives (proxy rewrites this)
forwardedHost string // X-Forwarded-Host set by proxy
forwardedPort string // X-Forwarded-Port set by proxy
forwardedProto string // X-Forwarded-Proto set by proxy
expectedHost string // what extractHostHeader should return (must match SDK)
}{
{
name: "HTTPS non-standard port",
clientUrl: "https://api.example.com:9000/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "9000",
forwardedProto: "https",
expectedHost: "api.example.com:9000",
},
{
name: "HTTPS standard port 443 (SDK strips, we must too)",
clientUrl: "https://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "443",
forwardedProto: "https",
expectedHost: "api.example.com",
},
{
name: "HTTP standard port 80 (SDK strips, we must too)",
clientUrl: "http://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "80",
forwardedProto: "http",
expectedHost: "api.example.com",
},
{
name: "HTTP non-standard port 8080",
clientUrl: "http://api.example.com:8080/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "8080",
forwardedProto: "http",
expectedHost: "api.example.com:8080",
},
{
name: "X-Forwarded-Host with port (Traefik style), HTTPS 443",
clientUrl: "https://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com:443",
forwardedPort: "443",
forwardedProto: "https",
expectedHost: "api.example.com",
},
{
name: "X-Forwarded-Host with port (Traefik style), HTTP 80",
clientUrl: "http://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com:80",
forwardedPort: "80",
forwardedProto: "http",
expectedHost: "api.example.com",
},
{
name: "no forwarded headers, direct access",
clientUrl: "http://localhost:8333/bucket/key",
backendHost: "localhost:8333",
forwardedHost: "",
forwardedPort: "",
forwardedProto: "",
expectedHost: "localhost:8333",
},
{
name: "empty proto with port 80 (defaults to http, strip)",
clientUrl: "http://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "80",
forwardedProto: "",
expectedHost: "api.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := &S3ApiServerOption{Config: tmpFile.Name()}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
require.True(t, iam.isEnabled())
// Step 1: Sign a request using the real AWS SDK v2 signer.
// The SDK applies its own SanitizeHostForHeader logic.
signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil)
require.NoError(t, err)
signReq.Host = signReq.URL.Host
err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1")
require.NoError(t, err)
// Step 2: Build the request as the proxy would deliver it to SeaweedFS.
proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil)
proxyReq.Host = tt.backendHost
// Copy signed auth headers
proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization"))
proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date"))
proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256"))
// Set forwarded headers
if tt.forwardedHost != "" {
proxyReq.Header.Set("X-Forwarded-Host", tt.forwardedHost)
}
if tt.forwardedPort != "" {
proxyReq.Header.Set("X-Forwarded-Port", tt.forwardedPort)
}
if tt.forwardedProto != "" {
proxyReq.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
// Step 3: Verify extractHostHeader returns the expected value.
// This is the critical correctness check: our extracted host must match
// what the SDK signed with, otherwise the signature will not match.
extractedHost := extractHostHeader(proxyReq, iam.externalHost)
assert.Equal(t, tt.expectedHost, extractedHost,
"extractHostHeader must return a value matching what AWS SDK signed with")
// Step 4: Verify the full signature matches.
identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrNone, errCode,
"Signature from real AWS SDK signer must verify successfully")
require.NotNil(t, identity)
assert.Equal(t, "test_user", identity.Name)
})
}
}

View File

@@ -0,0 +1,392 @@
/*
* The following code tries to reverse engineer the Amazon S3 APIs,
* and is mostly copied from minio implementation.
*/
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License.
package s3api
import (
"crypto/hmac"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// Whitelist resource list that will be used in query string for signature-V2 calculation.
// The list should be alphabetically sorted
var resourceList = []string{
"acl",
"delete",
"lifecycle",
"location",
"logging",
"notification",
"partNumber",
"policy",
"requestPayment",
"response-cache-control",
"response-content-disposition",
"response-content-encoding",
"response-content-language",
"response-content-type",
"response-expires",
"torrent",
"uploadId",
"uploads",
"versionId",
"versioning",
"versions",
"website",
}
// Verify if request has AWS Signature Version '2'.
func (iam *IdentityAccessManagement) isReqAuthenticatedV2(r *http.Request) (*Identity, s3err.ErrorCode) {
if isRequestSignatureV2(r) {
return iam.doesSignV2Match(r)
}
return iam.doesPresignV2SignatureMatch(r)
}
func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http.Header) (*Identity, s3err.ErrorCode) {
accessKey := formValues.Get("AWSAccessKeyId")
if accessKey == "" {
return nil, s3err.ErrMissingFields
}
identity, cred, found := iam.lookupByAccessKey(accessKey)
if !found {
// Log detailed error information for InvalidAccessKeyId (V2 POST)
iam.m.RLock()
availableKeyCount := len(iam.accessKeyIdent)
iam.m.RUnlock()
glog.Warningf("InvalidAccessKeyId (V2 POST): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
accessKey, availableKeyCount, iam.isAuthEnabled)
return nil, s3err.ErrInvalidAccessKeyID
}
// Check service account expiration
if cred.isCredentialExpired() {
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
accessKey, cred.Expiration, time.Now().Unix())
return nil, s3err.ErrAccessDenied
}
bucket := formValues.Get("bucket")
if !identity.CanDo(s3_constants.ACTION_WRITE, bucket, "") {
return nil, s3err.ErrAccessDenied
}
policy := formValues.Get("Policy")
if policy == "" {
return nil, s3err.ErrMissingFields
}
signature := formValues.Get("Signature")
if signature == "" {
return nil, s3err.ErrMissingFields
}
if !compareSignatureV2(signature, calculateSignatureV2(policy, cred.SecretKey)) {
return nil, s3err.ErrSignatureDoesNotMatch
}
return identity, s3err.ErrNone
}
// doesSignV2Match - Verify authorization header with calculated header in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
//
// returns ErrNone if the signature matches.
func (iam *IdentityAccessManagement) doesSignV2Match(r *http.Request) (*Identity, s3err.ErrorCode) {
v2Auth := r.Header.Get("Authorization")
accessKey, errCode := validateV2AuthHeader(v2Auth)
if errCode != s3err.ErrNone {
return nil, errCode
}
identity, cred, found := iam.lookupByAccessKey(accessKey)
if !found {
// Log detailed error information for InvalidAccessKeyId (V2 signed)
iam.m.RLock()
availableKeyCount := len(iam.accessKeyIdent)
iam.m.RUnlock()
glog.Warningf("InvalidAccessKeyId (V2 signed): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
accessKey, availableKeyCount, iam.isAuthEnabled)
return nil, s3err.ErrInvalidAccessKeyID
}
// Check service account expiration
if cred.isCredentialExpired() {
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
accessKey, cred.Expiration, time.Now().Unix())
return nil, s3err.ErrAccessDenied
}
expectedAuth := signatureV2(cred, r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header)
// Extract signatures from both auth headers
v2Signature := ""
expectedV2Signature := ""
// Extract signature from request header
if idx := strings.LastIndex(v2Auth, ":"); idx != -1 {
v2Signature = v2Auth[idx+1:]
}
// Extract signature from expected auth header
// This should always succeed if signatureV2 is working correctly
if idx := strings.LastIndex(expectedAuth, ":"); idx != -1 {
expectedV2Signature = expectedAuth[idx+1:]
} else {
// This indicates a bug in signatureV2 function
return nil, s3err.ErrSignatureDoesNotMatch
}
if !compareSignatureV2(v2Signature, expectedV2Signature) {
return nil, s3err.ErrSignatureDoesNotMatch
}
return identity, s3err.ErrNone
}
// doesPresignV2SignatureMatch - Verify query headers with calculated header in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
//
// returns ErrNone if the signature matches.
func (iam *IdentityAccessManagement) doesPresignV2SignatureMatch(r *http.Request) (*Identity, s3err.ErrorCode) {
query := r.URL.Query()
expires := query.Get("Expires")
if expires == "" {
return nil, s3err.ErrMissingFields
}
expireTimestamp, err := strconv.ParseInt(expires, 10, 64)
if err != nil {
return nil, s3err.ErrMalformedExpires
}
if time.Unix(expireTimestamp, 0).Before(time.Now().UTC()) {
return nil, s3err.ErrExpiredPresignRequest
}
accessKey := query.Get("AWSAccessKeyId")
if accessKey == "" {
glog.Warningf("InvalidAccessKeyId (V2 presigned): empty access key in request")
return nil, s3err.ErrInvalidAccessKeyID
}
signature := query.Get("Signature")
if signature == "" {
return nil, s3err.ErrMissingFields
}
identity, cred, found := iam.lookupByAccessKey(accessKey)
if !found {
// Log detailed error information for InvalidAccessKeyId (V2 presigned)
iam.m.RLock()
availableKeyCount := len(iam.accessKeyIdent)
iam.m.RUnlock()
glog.Warningf("InvalidAccessKeyId (V2 presigned): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
accessKey, availableKeyCount, iam.isAuthEnabled)
return nil, s3err.ErrInvalidAccessKeyID
}
// Check service account expiration
if cred.isCredentialExpired() {
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
accessKey, cred.Expiration, time.Now().Unix())
return nil, s3err.ErrAccessDenied
}
expectedSignature := preSignatureV2(cred, r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header, expires)
if !compareSignatureV2(signature, expectedSignature) {
return nil, s3err.ErrSignatureDoesNotMatch
}
return identity, s3err.ErrNone
}
// validateV2AuthHeader validates AWS Signature Version '2' authentication header.
func validateV2AuthHeader(v2Auth string) (accessKey string, errCode s3err.ErrorCode) {
if v2Auth == "" {
return "", s3err.ErrAuthHeaderEmpty
}
// Signature V2 authorization header format:
// Authorization: AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//yllqDzg=
const signV2AlgorithmPrefix = signV2Algorithm + " "
if !strings.HasPrefix(v2Auth, signV2AlgorithmPrefix) {
return "", s3err.ErrSignatureVersionNotSupported
}
// Strip off the Algorithm prefix.
v2Auth = v2Auth[len(signV2Algorithm)+1:]
authFields := strings.Split(v2Auth, ":")
if len(authFields) != 2 {
return "", s3err.ErrMissingFields
}
// The first field is Access Key ID.
if authFields[0] == "" {
return "", s3err.ErrInvalidAccessKeyID
}
// The second field is signature.
if authFields[1] == "" {
return "", s3err.ErrMissingFields
}
return authFields[0], s3err.ErrNone
}
// signatureV2 - calculates signature version 2 for request.
func signatureV2(cred *Credential, method string, encodedResource string, encodedQuery string, headers http.Header) string {
stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, "")
signature := calculateSignatureV2(stringToSign, cred.SecretKey)
return signV2Algorithm + " " + cred.AccessKey + ":" + signature
}
// getStringToSignV2 - string to sign in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
func getStringToSignV2(method string, encodedResource, encodedQuery string, headers http.Header, expires string) string {
canonicalHeaders := canonicalizedAmzHeadersV2(headers)
if len(canonicalHeaders) > 0 {
canonicalHeaders += "\n"
}
// From the Amazon docs:
//
// StringToSign = HTTP-Verb + "\n" +
// Content-MD5 + "\n" +
// Content-Type + "\n" +
// Date + "\n" +
// CanonicalizedAmzHeaders +
// CanonicalizedResource;
stringToSign := method + "\n"
stringToSign += headers.Get("Content-Md5") + "\n"
stringToSign += headers.Get("Content-Type") + "\n"
if expires != "" {
stringToSign += expires + "\n"
} else {
stringToSign += headers.Get("Date") + "\n"
if v := headers.Get("x-amz-date"); v != "" {
stringToSign = strings.Replace(stringToSign, headers.Get("Date")+"\n", "\n", -1)
}
}
stringToSign += canonicalHeaders
stringToSign += canonicalizedResourceV2(encodedResource, encodedQuery)
return stringToSign
}
// canonicalizedResourceV2 - canonicalize the resource string for signature V2.
func canonicalizedResourceV2(encodedResource, encodedQuery string) string {
queries := strings.Split(encodedQuery, "&")
keyval := make(map[string]string)
for _, query := range queries {
key := query
val := ""
index := strings.Index(query, "=")
if index != -1 {
key = query[:index]
val = query[index+1:]
}
keyval[key] = val
}
var canonicalQueries []string
for _, resource := range resourceList {
if val, ok := keyval[resource]; ok {
if val == "" {
canonicalQueries = append(canonicalQueries, resource)
continue
}
canonicalQueries = append(canonicalQueries, resource+"="+val)
}
}
// The queries will be already sorted as resourceList is sorted.
if len(canonicalQueries) == 0 {
return encodedResource
}
// If queries are present then the canonicalized resource is set to encodedResource + "?" + strings.Join(canonicalQueries, "&")
return encodedResource + "?" + strings.Join(canonicalQueries, "&")
}
// canonicalizedAmzHeadersV2 - canonicalize the x-amz-* headers for signature V2.
func canonicalizedAmzHeadersV2(headers http.Header) string {
var keys []string
keyval := make(map[string]string)
for key := range headers {
lkey := strings.ToLower(key)
if !strings.HasPrefix(lkey, "x-amz-") {
continue
}
keys = append(keys, lkey)
keyval[lkey] = strings.Join(headers[key], ",")
}
sort.Strings(keys)
var canonicalHeaders []string
for _, key := range keys {
canonicalHeaders = append(canonicalHeaders, key+":"+keyval[key])
}
return strings.Join(canonicalHeaders, "\n")
}
// calculateSignatureV2 - calculates signature version 2.
func calculateSignatureV2(stringToSign string, secret string) string {
hm := hmac.New(sha1.New, []byte(secret))
hm.Write([]byte(stringToSign))
return base64.StdEncoding.EncodeToString(hm.Sum(nil))
}
// compareSignatureV2 returns true if and only if both signatures
// are equal. The signatures are expected to be base64 encoded strings
// according to the AWS S3 signature V2 spec.
func compareSignatureV2(sig1, sig2 string) bool {
// Decode signature string to binary byte-sequence representation is required
// as Base64 encoding of a value is not unique:
// For example "aGVsbG8=" and "aGVsbG8=\r" will result in the same byte slice.
signature1, err := base64.StdEncoding.DecodeString(sig1)
if err != nil {
return false
}
signature2, err := base64.StdEncoding.DecodeString(sig2)
if err != nil {
return false
}
return subtle.ConstantTimeCompare(signature1, signature2) == 1
}
// Return signature-v2 for the presigned request.
func preSignatureV2(cred *Credential, method string, encodedResource string, encodedQuery string, headers http.Header, expires string) string {
stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, expires)
return calculateSignatureV2(stringToSign, cred.SecretKey)
}

View File

@@ -0,0 +1,367 @@
package s3api
import (
"net/http"
"strconv"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
func setupTestIAMForV2Auth() *IdentityAccessManagement {
iam := &IdentityAccessManagement{
identities: []*Identity{},
accessKeyIdent: make(map[string]*Identity),
}
testCred := &Credential{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
testIdentity := &Identity{
Name: "testUser",
Account: &AccountAdmin,
Credentials: []*Credential{testCred},
Actions: []Action{
s3_constants.ACTION_ADMIN,
},
}
iam.identities = append(iam.identities, testIdentity)
iam.accessKeyIdent[testCred.AccessKey] = testIdentity
return iam
}
func TestValidateV2AuthHeader(t *testing.T) {
tests := []struct {
name string
authHeader string
expectedAccessKey string
expectedError s3err.ErrorCode
}{
{
name: "valid auth header with space",
authHeader: "AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//yllqDzg=",
expectedAccessKey: "AKIAIOSFODNN7EXAMPLE",
expectedError: s3err.ErrNone,
},
{
name: "empty auth header",
authHeader: "",
expectedError: s3err.ErrAuthHeaderEmpty,
},
{
name: "wrong algorithm prefix",
authHeader: "HMAC AKIAIOSFODNN7EXAMPLE:signature",
expectedError: s3err.ErrSignatureVersionNotSupported,
},
{
name: "algorithm prefix without space",
authHeader: "AWSXAKIAIOSFODNN7EXAMPLE:signature",
expectedError: s3err.ErrSignatureVersionNotSupported,
},
{
name: "missing colon separator",
authHeader: "AWS AKIAIOSFODNN7EXAMPLE",
expectedError: s3err.ErrMissingFields,
},
{
name: "empty access key",
authHeader: "AWS :signature",
expectedError: s3err.ErrInvalidAccessKeyID,
},
{
name: "empty signature",
authHeader: "AWS AKIAIOSFODNN7EXAMPLE:",
expectedError: s3err.ErrMissingFields,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
accessKey, errCode := validateV2AuthHeader(tt.authHeader)
if errCode != tt.expectedError {
t.Errorf("validateV2AuthHeader() error = %v, want %v", errCode, tt.expectedError)
}
if errCode == s3err.ErrNone && accessKey != tt.expectedAccessKey {
t.Errorf("validateV2AuthHeader() accessKey = %q, want %q", accessKey, tt.expectedAccessKey)
}
})
}
}
func TestSignatureV2Format(t *testing.T) {
cred := &Credential{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
headers := http.Header{}
headers.Set("Date", "Mon, 09 Sep 2011 23:36:00 GMT")
signature := signatureV2(cred, "GET", "/bucket/object", "", headers)
// Verify format: "AWS <AccessKey>:<Signature>" with space after AWS
expectedPrefix := "AWS " + cred.AccessKey + ":"
if len(signature) < len(expectedPrefix) {
t.Fatalf("Signature too short: %s", signature)
}
actualPrefix := signature[:len(expectedPrefix)]
if actualPrefix != expectedPrefix {
t.Errorf("Signature prefix = %q, want %q", actualPrefix, expectedPrefix)
}
}
func TestDoesSignV2Match(t *testing.T) {
iam := setupTestIAMForV2Auth()
cred := &Credential{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
tests := []struct {
name string
method string
path string
query string
headers map[string]string
authOverride string
expectedError s3err.ErrorCode
expectIdent bool
}{
{
name: "valid GET request",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
expectedError: s3err.ErrNone,
expectIdent: true,
},
{
name: "valid PUT request with content headers",
method: "PUT",
path: "/bucket/object",
query: "",
headers: map[string]string{
"Date": "Mon, 09 Sep 2011 23:36:00 GMT",
"Content-Type": "text/plain",
"Content-Md5": "c8fdb181845a4ca6b8fec737b3581d76",
},
expectedError: s3err.ErrNone,
expectIdent: true,
},
{
name: "request with query parameters",
method: "GET",
path: "/bucket/object",
query: "acl&versionId=123",
headers: map[string]string{
"Date": "Mon, 09 Sep 2011 23:36:00 GMT",
},
expectedError: s3err.ErrNone,
expectIdent: true,
},
{
name: "request with x-amz headers",
method: "PUT",
path: "/bucket/object",
query: "",
headers: map[string]string{
"Date": "Mon, 09 Sep 2011 23:36:00 GMT",
"x-amz-storage-class": "REDUCED_REDUNDANCY",
"x-amz-meta-custom": "value",
},
expectedError: s3err.ErrNone,
expectIdent: true,
},
{
name: "invalid signature",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
authOverride: "AWS AKIAIOSFODNN7EXAMPLE:invalidSignature123456==",
expectedError: s3err.ErrSignatureDoesNotMatch,
expectIdent: false,
},
{
name: "non-existent access key",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
authOverride: "AWS NONEXISTENTKEY:signature==",
expectedError: s3err.ErrInvalidAccessKeyID,
expectIdent: false,
},
{
name: "empty authorization header",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
authOverride: "",
expectedError: s3err.ErrAuthHeaderEmpty,
expectIdent: false,
},
{
name: "malformed auth - missing signature",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
authOverride: "AWS AKIAIOSFODNN7EXAMPLE",
expectedError: s3err.ErrMissingFields,
expectIdent: false,
},
{
name: "malformed auth - wrong prefix",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
authOverride: "HMAC AKIAIOSFODNN7EXAMPLE:sig",
expectedError: s3err.ErrSignatureVersionNotSupported,
expectIdent: false,
},
{
name: "malformed auth - no space after AWS",
method: "GET",
path: "/bucket/object",
query: "",
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
authOverride: "AWSAKIAIOSFODNN7EXAMPLE:signature==",
expectedError: s3err.ErrSignatureVersionNotSupported,
expectIdent: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := "http://example.com" + tt.path
if tt.query != "" {
url += "?" + tt.query
}
req, err := http.NewRequest(tt.method, url, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
for key, value := range tt.headers {
req.Header.Set(key, value)
}
var authHeader string
if tt.authOverride != "" {
authHeader = tt.authOverride
} else {
authHeader = signatureV2(cred, req.Method, req.URL.Path, req.URL.Query().Encode(), req.Header)
}
if tt.name != "empty authorization header" {
req.Header.Set("Authorization", authHeader)
}
identity, errCode := iam.doesSignV2Match(req)
if errCode != tt.expectedError {
t.Errorf("doesSignV2Match() error = %v, want %v", errCode, tt.expectedError)
}
if tt.expectIdent && identity == nil {
t.Error("Expected non-nil identity")
}
if !tt.expectIdent && identity != nil {
t.Error("Expected nil identity")
}
if identity != nil && identity.Name != "testUser" {
t.Errorf("Identity name = %q, want %q", identity.Name, "testUser")
}
})
}
}
func TestDoesSignV2MatchUnicodePath(t *testing.T) {
iam := setupTestIAMForV2Auth()
cred := &Credential{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
tests := []struct {
name string
path string
}{
{
name: "CJK conversation key from ai-dial-core",
path: "/dialcore/core/Users/test-user/conversations/qwen3.5-35b-a3b__%E5%A4%9A%E7%9D%87%E3%80%82",
},
{
name: "Japanese filename from issue 8598",
path: "/voyage-dev1/public/1773200112088-998898991/%E3%81%9F%E3%81%82%E3%81%84%E3%81%BEd%C2%B7.jpg",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.path, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Date", "Mon, 09 Sep 2011 23:36:00 GMT")
// Clients (jclouds, AWS SDK) sign percent-encoded paths, not decoded Path.
authHeader := signatureV2(cred, req.Method, tt.path, req.URL.Query().Encode(), req.Header)
req.Header.Set("Authorization", authHeader)
identity, errCode := iam.doesSignV2Match(req)
if errCode != s3err.ErrNone {
t.Fatalf("doesSignV2Match() error = %v, want %v; path=%q escaped=%q",
errCode, s3err.ErrNone, req.URL.Path, req.URL.EscapedPath())
}
if identity == nil {
t.Fatal("Expected non-nil identity")
}
})
}
}
func TestDoesPresignV2MatchUnicodePath(t *testing.T) {
iam := setupTestIAMForV2Auth()
cred := &Credential{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
path := "/dialcore/core/Users/test-user/conversations/qwen3.5-35b-a3b__%E5%A4%9A%E7%9D%87%E3%80%82"
expires := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
req, err := http.NewRequest(http.MethodGet, "http://example.com"+path, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
query := req.URL.Query()
query.Set("AWSAccessKeyId", cred.AccessKey)
query.Set("Expires", expires)
query.Set("Signature", preSignatureV2(cred, req.Method, path, query.Encode(), req.Header, expires))
req.URL.RawQuery = query.Encode()
identity, errCode := iam.doesPresignV2SignatureMatch(req)
if errCode != s3err.ErrNone {
t.Fatalf("doesPresignV2SignatureMatch() error = %v, want %v; path=%q escaped=%q",
errCode, s3err.ErrNone, req.URL.Path, req.URL.EscapedPath())
}
if identity == nil {
t.Fatal("Expected non-nil identity")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,347 @@
package s3api
import (
"context"
"net/http"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// An STS session owns resources as its own principal (OIDC subject, or the
// assumed-role user when absent), not the shared admin account.
func TestValidateSTSSessionTokenAssignsDistinctAccount(t *testing.T) {
cases := []struct {
name string
subject string
assumedRoleUser string
wantPrincipal string
}{
{"prefers the subject", "alice", "ReadOnlyRole/alice", "alice"},
{"falls back to the assumed-role user", "", "ReadOnlyRole/bob", "ReadOnlyRole/bob"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: tc.assumedRoleUser,
Principal: "arn:aws:sts:::assumed-role/" + tc.assumedRoleUser,
Subject: tc.subject,
SessionName: "sess",
Credentials: &sts.Credentials{
AccessKeyId: "AKIATEST",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"ReadOnly"},
}, nil
},
},
}
req, err := http.NewRequest(http.MethodGet, "http://s3/test", nil)
require.NoError(t, err)
identity, _, errCode := iam.validateSTSSessionToken(req, "session-token", "AKIATEST")
require.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity.Account)
assert.Equal(t, tc.wantPrincipal, identity.Account.Id, "STS session owns resources as its principal")
assert.Equal(t, tc.wantPrincipal, identity.Name)
assert.NotEqual(t, AccountAdmin.Id, identity.Account.Id, "STS session must not share the admin account")
assert.False(t, identity.isAdmin(), "an STS session has no admin action")
})
}
}
// MockIAMIntegration is a mock implementation of IAM integration for testing
type MockIAMIntegration struct {
authenticateJWTFunc func(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
authorizeFunc func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode
validateTrustPolicyFunc func(ctx context.Context, roleArn, principalArn string) error
validateSessionFunc func(ctx context.Context, token string) (*sts.SessionInfo, error)
authCalled bool
}
func (m *MockIAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
m.authCalled = true
if m.authorizeFunc != nil {
return m.authorizeFunc(ctx, identity, action, bucket, object, r)
}
return s3err.ErrNone
}
func (m *MockIAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) {
if m.authenticateJWTFunc != nil {
return m.authenticateJWTFunc(ctx, r)
}
return nil, s3err.ErrNotImplemented
}
func (m *MockIAMIntegration) ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error) {
if m.validateSessionFunc != nil {
return m.validateSessionFunc(ctx, token)
}
return nil, nil // Not needed for these tests
}
func (m *MockIAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
if m.validateTrustPolicyFunc != nil {
return m.validateTrustPolicyFunc(ctx, roleArn, principalArn)
}
return nil
}
func (m *MockIAMIntegration) DefaultAllow() bool {
return true
}
// TestVerifyV4SignatureWithSTSIdentity tests that verifyV4Signature properly handles STS identities
// by falling back to IAM authorization when shouldCheckPermissions is true
func TestVerifyV4SignatureWithSTSIdentity(t *testing.T) {
tests := []struct {
name string
identity *Identity
shouldCheckPermissions bool
iamIntegration *MockIAMIntegration
expectedError s3err.ErrorCode
description string
}{
{
name: "STS identity with IAM integration - should authorize via IAM",
identity: &Identity{
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
Account: &AccountAdmin,
Actions: []Action{}, // Empty actions = STS identity
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
},
shouldCheckPermissions: true,
iamIntegration: &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
// Simulate successful IAM authorization
return s3err.ErrNone
},
},
expectedError: s3err.ErrNone,
description: "STS identity should be authorized via IAM when Actions is empty",
},
{
name: "STS identity with IAM integration - IAM denies",
identity: &Identity{
Name: "arn:aws:sts::assumed-role/readOnlyRole/s3-session",
Account: &AccountAdmin,
Actions: []Action{}, // Empty actions = STS identity
PrincipalArn: "arn:aws:sts::assumed-role/readOnlyRole/s3-session",
},
shouldCheckPermissions: true,
iamIntegration: &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
// Simulate IAM denying access (e.g., read-only role trying to write)
if action == s3_constants.ACTION_WRITE {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
},
},
expectedError: s3err.ErrAccessDenied,
description: "STS identity should be denied when IAM denies access",
},
{
name: "STS identity without IAM integration - should deny",
identity: &Identity{
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
Account: &AccountAdmin,
Actions: []Action{}, // Empty actions = STS identity
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
},
shouldCheckPermissions: true,
iamIntegration: nil, // No IAM integration
expectedError: s3err.ErrAccessDenied,
description: "STS identity should be denied when no IAM integration is available",
},
{
name: "Traditional identity with Actions - should use CanDo",
identity: &Identity{
Name: "traditional-user",
Account: &AccountAdmin,
Actions: []Action{s3_constants.ACTION_WRITE}, // Has actions = traditional identity
},
shouldCheckPermissions: true,
iamIntegration: nil, // IAM integration not needed for traditional identities
expectedError: s3err.ErrNone,
description: "Traditional identity with Actions should use CanDo check",
},
{
name: "Traditional identity with Actions - CanDo denies",
identity: &Identity{
Name: "read-only-user",
Account: &AccountAdmin,
Actions: []Action{s3_constants.ACTION_READ}, // Only has READ action
},
shouldCheckPermissions: true,
iamIntegration: nil,
expectedError: s3err.ErrAccessDenied,
description: "Traditional identity should be denied when CanDo fails (PUT requires WRITE)",
},
{
name: "shouldCheckPermissions false - skip authorization",
identity: &Identity{
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
Account: &AccountAdmin,
Actions: []Action{}, // Empty actions = STS identity
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
},
shouldCheckPermissions: false, // Skip permission check
iamIntegration: nil,
expectedError: s3err.ErrNone,
description: "When shouldCheckPermissions is false, authorization should be skipped",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock request for PUT operation (requires WRITE action)
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object", nil)
require.NoError(t, err)
req.Header.Set("Host", "s3.amazonaws.com")
// Set up mux route vars for GetBucketAndObject to work
req = mux.SetURLVars(req, map[string]string{
"bucket": "test-bucket",
"object": "test-object",
})
// For STS identities, add session token header to trigger STS-v4 auth path
if len(tt.identity.Actions) == 0 && tt.iamIntegration != nil {
req.Header.Set("X-Amz-Security-Token", "test-session-token")
}
// Mock the permission check logic from verifyV4Signature (now centralized in VerifyActionPermission)
var errCode s3err.ErrorCode
if tt.shouldCheckPermissions {
bucket, object := s3_constants.GetBucketAndObject(req)
action := s3_constants.ACTION_READ
if req.Method != http.MethodGet && req.Method != http.MethodHead {
action = s3_constants.ACTION_WRITE
}
// Create minimal IAM instance with mock integration
var integration IAMIntegration
if tt.iamIntegration != nil {
integration = tt.iamIntegration
}
iam := &IdentityAccessManagement{
iamIntegration: integration,
}
errCode = iam.VerifyActionPermission(req, tt.identity, Action(action), bucket, object)
}
// Verify the result
assert.Equal(t, tt.expectedError, errCode, tt.description)
// Additional verification for STS identities
if len(tt.identity.Actions) == 0 && tt.shouldCheckPermissions {
if tt.iamIntegration != nil {
// When IAM integration exists, it should have been called
assert.True(t, tt.iamIntegration.authCalled, "IAM integration should have been called for STS identity")
} else {
assert.Equal(t, s3err.ErrAccessDenied, errCode, "STS identity should be denied without IAM integration")
}
}
})
}
}
// TestVerifyV4SignatureSTSStreamingUpload tests the specific scenario from the bug report:
// STS identities in streaming/chunked uploads should be authorized via IAM
func TestVerifyV4SignatureSTSStreamingUpload(t *testing.T) {
// Create an STS identity (empty Actions)
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
Account: &AccountAdmin,
Actions: []Action{}, // Empty - this is an STS identity
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
}
// Track whether IAM authorization was called
iamAuthCalled := false
// Create IAM integration mock that allows the action
iamMock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
iamAuthCalled = true
// Verify we're checking the right identity
assert.Equal(t, "arn:aws:sts::assumed-role/adminRole/s3-session", identity.Name)
assert.Equal(t, s3_constants.ACTION_WRITE, string(action))
assert.Equal(t, "test-bucket", bucket)
assert.Equal(t, "test-object", object)
return s3err.ErrNone
},
}
// Create a streaming upload request (PUT with streaming content)
req, err := http.NewRequest("PUT", "/test-bucket/test-object", nil)
require.NoError(t, err)
req.Host = "s3.amazonaws.com"
req.Header.Set("Host", "s3.amazonaws.com")
req.Header.Set("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD")
req.Header.Set("Content-Encoding", "aws-chunked")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
// Simulate the permission check with shouldCheckPermissions=true
// This is what happens in calculateSeedSignature -> verifyV4Signature(r, true)
bucket := "test-bucket"
object := "test-object"
action := s3_constants.ACTION_WRITE
var errCode s3err.ErrorCode
// Create minimal IAM instance logic
iam := &IdentityAccessManagement{
iamIntegration: iamMock,
}
errCode = iam.VerifyActionPermission(req, stsIdentity, Action(action), bucket, object)
// Verify that the STS identity is authorized via IAM
assert.Equal(t, s3err.ErrNone, errCode, "STS identity should be authorized via IAM for streaming upload")
assert.True(t, iamAuthCalled, "IAM authorization should have been called for STS identity")
}
// TestVerifyV4SignatureActionDetermination tests that the correct action is determined
// based on the HTTP method
func TestVerifyV4SignatureActionDetermination(t *testing.T) {
tests := []struct {
method string
expectedAction string
}{
{http.MethodGet, s3_constants.ACTION_READ},
{http.MethodHead, s3_constants.ACTION_READ},
{http.MethodPut, s3_constants.ACTION_WRITE},
{http.MethodPost, s3_constants.ACTION_WRITE},
{http.MethodDelete, s3_constants.ACTION_WRITE},
}
for _, tt := range tests {
t.Run(tt.method, func(t *testing.T) {
req, err := http.NewRequest(tt.method, "http://s3.amazonaws.com/bucket/object", nil)
require.NoError(t, err)
// Determine action the same way verifyV4Signature does
action := s3_constants.ACTION_READ
if req.Method != http.MethodGet && req.Method != http.MethodHead {
action = s3_constants.ACTION_WRITE
}
assert.Equal(t, tt.expectedAction, action, "Action should match expected for method %s", tt.method)
})
}
}

View File

@@ -0,0 +1,679 @@
package s3api
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"slices"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
func TestExtractV4AuthInfoFromHeader_S3Tables(t *testing.T) {
now := time.Now().UTC()
dateStr := now.Format(iso8601Format)
tests := []struct {
name string
service string
body string
expectAutoHash bool
}{
{
name: "s3 service should not auto-hash",
service: "s3",
body: "hello",
expectAutoHash: false,
},
{
name: "s3tables service should auto-hash",
service: "s3tables",
body: "hello",
expectAutoHash: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := bytes.NewReader([]byte(tt.body))
req, _ := http.NewRequest(http.MethodPost, "http://localhost/", body)
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/%s/us-east-1/%s/aws4_request, SignedHeaders=host, Signature=dummy",
now.Format(yyyymmdd), tt.service)
req.Header.Set("Authorization", authHeader)
req.Header.Set("x-amz-date", dateStr)
authInfo, errCode := extractV4AuthInfoFromHeader(req)
if errCode != s3err.ErrNone {
t.Fatalf("extractV4AuthInfoFromHeader failed: %v", errCode)
}
if tt.expectAutoHash {
expectedHash := sha256.Sum256([]byte(tt.body))
expectedHashStr := hex.EncodeToString(expectedHash[:])
if authInfo.HashedPayload != expectedHashStr {
t.Errorf("Expected auto-hashed payload %s, got %s", expectedHashStr, authInfo.HashedPayload)
}
} else {
if authInfo.HashedPayload != emptySHA256 {
t.Errorf("Expected non-auto-hashed payload %s (emptySHA256), got %s", emptySHA256, authInfo.HashedPayload)
}
}
})
}
}
func TestParseSignedHeaderRejectsEmptyHeaderNames(t *testing.T) {
cases := []string{
"SignedHeaders=host;",
"SignedHeaders=;host",
"SignedHeaders=host;;x-amz-date",
}
for _, tc := range cases {
if _, errCode := parseSignedHeader(tc); errCode != s3err.ErrMissingFields {
t.Fatalf("parseSignedHeader(%q) errCode = %v, want %v", tc, errCode, s3err.ErrMissingFields)
}
}
}
func TestExtractV4AuthInfoFromQueryRejectsEmptySignedHeaderNames(t *testing.T) {
now := time.Now().UTC()
cases := []string{
"host;",
";host",
"host;;x-amz-date",
}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://localhost/bucket/object", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
query := req.URL.Query()
query.Set("X-Amz-Algorithm", signV4Algorithm)
query.Set("X-Amz-Credential", fmt.Sprintf("AKIAIOSFODNN7EXAMPLE/%s/us-east-1/s3/aws4_request", now.Format(yyyymmdd)))
query.Set("X-Amz-Date", now.Format(iso8601Format))
query.Set("X-Amz-Expires", "60")
query.Set("X-Amz-Signature", "dummy")
query.Set("X-Amz-SignedHeaders", tc)
req.URL.RawQuery = query.Encode()
if _, errCode := extractV4AuthInfoFromQuery(req); errCode != s3err.ErrMissingFields {
t.Fatalf("extractV4AuthInfoFromQuery(%q) errCode = %v, want %v", tc, errCode, s3err.ErrMissingFields)
}
})
}
}
func TestGetCanonicalQueryString(t *testing.T) {
tests := []struct {
name string
target string
isPresigned bool
want string
}{
{
name: "sorts repeated values",
target: "http://localhost/bucket/key?partNumber=2&partNumber=10&uploadId=z",
want: "partNumber=10&partNumber=2&uploadId=z",
},
{
name: "removes presigned signature",
target: "http://localhost/bucket/key?X-Amz-Date=20260618T000000Z&X-Amz-Signature=dummy&X-Amz-SignedHeaders=host",
isPresigned: true,
want: "X-Amz-Date=20260618T000000Z&X-Amz-SignedHeaders=host",
},
{
name: "sorts mixed single and repeated parameters",
target: "http://localhost/bucket/key?z=last&a=2&bucket=b&a=1",
want: "a=1&a=2&bucket=b&z=last",
},
{
name: "encodes query parameter values",
target: "http://localhost/bucket/key?prefix=photos/2026&marker=a%2Bb",
want: "marker=a%2Bb&prefix=photos%2F2026",
},
{
name: "empty query string",
target: "http://localhost/bucket/key",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, tt.target, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
got := getCanonicalQueryString(req, tt.isPresigned)
if got != tt.want {
t.Fatalf("canonical query = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildPathWithForwardedPrefix(t *testing.T) {
tests := []struct {
name string
forwardedPrefix string
urlPath string
expected string
}{
{
name: "empty prefix returns urlPath",
forwardedPrefix: "",
urlPath: "/bucket/obj",
expected: "/bucket/obj",
},
{
name: "prefix without trailing slash",
forwardedPrefix: "/storage",
urlPath: "/bucket/obj",
expected: "/storage/bucket/obj",
},
{
name: "prefix with trailing slash",
forwardedPrefix: "/storage/",
urlPath: "/bucket/obj",
expected: "/storage/bucket/obj",
},
{
name: "prefix without leading slash",
forwardedPrefix: "storage",
urlPath: "/bucket/obj",
expected: "/storage/bucket/obj",
},
{
name: "prefix without leading slash and with trailing slash",
forwardedPrefix: "storage/",
urlPath: "/bucket/obj",
expected: "/storage/bucket/obj",
},
{
name: "preserve double slashes in key",
forwardedPrefix: "/storage",
urlPath: "/bucket//obj",
expected: "/storage/bucket//obj",
},
{
name: "preserve trailing slash in urlPath",
forwardedPrefix: "/storage",
urlPath: "/bucket/folder/",
expected: "/storage/bucket/folder/",
},
{
name: "preserve trailing slash with prefix having trailing slash",
forwardedPrefix: "/storage/",
urlPath: "/bucket/folder/",
expected: "/storage/bucket/folder/",
},
{
name: "root path",
forwardedPrefix: "/storage",
urlPath: "/",
expected: "/storage/",
},
{
name: "complex key with multiple slashes",
forwardedPrefix: "/api/v1",
urlPath: "/bucket/path//with///slashes",
expected: "/api/v1/bucket/path//with///slashes",
},
{
name: "urlPath without leading slash",
forwardedPrefix: "/storage",
urlPath: "bucket/obj",
expected: "/storage/bucket/obj",
},
{
name: "empty urlPath with prefix",
forwardedPrefix: "/s3",
urlPath: "",
expected: "/s3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildPathWithForwardedPrefix(tt.forwardedPrefix, tt.urlPath)
if result != tt.expected {
t.Errorf("buildPathWithForwardedPrefix(%q, %q) = %q, want %q",
tt.forwardedPrefix, tt.urlPath, result, tt.expected)
}
})
}
}
// TestExtractHostHeader tests the extractHostHeader function with various scenarios
func TestExtractHostHeader(t *testing.T) {
tests := []struct {
name string
hostHeader string
forwardedHost string
forwardedPort string
forwardedProto string
externalHost string
expected string
}{
{
name: "basic host without forwarding",
hostHeader: "example.com",
forwardedHost: "",
forwardedPort: "",
forwardedProto: "",
expected: "example.com",
},
{
name: "host with port without forwarding",
hostHeader: "example.com:8080",
forwardedHost: "",
forwardedPort: "",
forwardedProto: "",
expected: "example.com:8080",
},
{
name: "X-Forwarded-Host without port",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "",
forwardedProto: "",
expected: "example.com",
},
{
name: "X-Forwarded-Host with X-Forwarded-Port (HTTP non-standard)",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "8080",
forwardedProto: "http",
expected: "example.com:8080",
},
{
name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS non-standard)",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "8443",
forwardedProto: "https",
expected: "example.com:8443",
},
{
name: "X-Forwarded-Host with X-Forwarded-Port (HTTP standard port 80)",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "80",
forwardedProto: "http",
expected: "example.com",
},
{
name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS standard port 443)",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "443",
forwardedProto: "https",
expected: "example.com",
},
// Issue #6649: X-Forwarded-Host already contains port (Traefik/HAProxy style)
{
name: "X-Forwarded-Host with port already included (should not add port again)",
hostHeader: "backend:8333",
forwardedHost: "127.0.0.1:8433",
forwardedPort: "8433",
forwardedProto: "https",
expected: "127.0.0.1:8433",
},
{
name: "X-Forwarded-Host with standard port already included (HTTPS 443)",
hostHeader: "backend:8333",
forwardedHost: "example.com:443",
forwardedPort: "443",
forwardedProto: "https",
expected: "example.com",
},
{
name: "X-Forwarded-Host with port, no X-Forwarded-Port header",
hostHeader: "backend:8333",
forwardedHost: "example.com:9000",
forwardedPort: "",
forwardedProto: "http",
expected: "example.com:9000",
},
// IPv6 test cases
{
name: "IPv6 address with brackets and port in X-Forwarded-Host",
hostHeader: "backend:8333",
forwardedHost: "[::1]:8080",
forwardedPort: "8080",
forwardedProto: "http",
expected: "[::1]:8080",
},
{
name: "IPv6 address without brackets, should add brackets with port",
hostHeader: "backend:8333",
forwardedHost: "::1",
forwardedPort: "8080",
forwardedProto: "http",
expected: "[::1]:8080",
},
{
name: "IPv6 address without brackets and standard port, should strip default port",
hostHeader: "backend:8333",
forwardedHost: "::1",
forwardedPort: "80",
forwardedProto: "http",
expected: "::1",
},
{
name: "IPv6 address without brackets and standard HTTPS port, should strip default port",
hostHeader: "backend:8333",
forwardedHost: "2001:db8::1",
forwardedPort: "443",
forwardedProto: "https",
expected: "2001:db8::1",
},
{
name: "IPv6 address with brackets but no port, should add port",
hostHeader: "backend:8333",
forwardedHost: "[2001:db8::1]",
forwardedPort: "8080",
forwardedProto: "http",
expected: "[2001:db8::1]:8080",
},
{
name: "IPv6 full address with brackets and default port (should strip default port)",
hostHeader: "backend:8333",
forwardedHost: "[2001:db8:85a3::8a2e:370:7334]:443",
forwardedPort: "443",
forwardedProto: "https",
expected: "2001:db8:85a3::8a2e:370:7334",
},
{
name: "IPv4-mapped IPv6 address without brackets, should add brackets with port",
hostHeader: "backend:8333",
forwardedHost: "::ffff:127.0.0.1",
forwardedPort: "8080",
forwardedProto: "http",
expected: "[::ffff:127.0.0.1]:8080",
},
{
name: "Simple port 442",
hostHeader: "bucket.domain.com:442",
expected: "bucket.domain.com:442",
},
{
name: "Port 442 with X-Forwarded-Host",
hostHeader: "backend:8333",
forwardedHost: "bucket.domain.com:442",
expected: "bucket.domain.com:442",
},
{
name: "Port 442 with X-Forwarded-Port",
hostHeader: "backend:8333",
forwardedHost: "bucket.domain.com",
forwardedPort: "442",
expected: "bucket.domain.com:442",
},
{
name: "HTTPS with port 442 (should NOT strip)",
hostHeader: "bucket.domain.com:442",
forwardedProto: "https",
expected: "bucket.domain.com:442",
},
{
name: "X-Forwarded-Host with multiple hosts (including port)",
forwardedHost: "bucket.domain.com:442, internal.proxy",
expected: "bucket.domain.com:442",
},
{
name: "IPv6 with port",
hostHeader: "[2001:db8::1]:442",
expected: "[2001:db8::1]:442",
},
{
name: "X-Forwarded-Host with port 442, but X-Forwarded-Port is 80 (should PREFER 442)",
forwardedHost: "bucket.domain.com:442",
forwardedPort: "80",
forwardedProto: "http",
expected: "bucket.domain.com:442",
},
// externalHost override tests
{
name: "externalHost overrides everything",
hostHeader: "backend:8333",
externalHost: "api.example.com:9000",
expected: "api.example.com:9000",
},
{
name: "externalHost overrides X-Forwarded-Host",
hostHeader: "backend:8333",
forwardedHost: "proxy.example.com",
forwardedPort: "443",
forwardedProto: "https",
externalHost: "api.example.com",
expected: "api.example.com",
},
{
name: "externalHost with IPv6",
hostHeader: "backend:8333",
externalHost: "[::1]:9000",
expected: "[::1]:9000",
},
// Bug fix: X-Forwarded-Port should not override more specific ports in other headers
{
name: "User reported case: X-Forwarded-Port misreports 443 but Host has 30007",
hostHeader: "storage-stgops.mt.mtnet:30007",
forwardedHost: "storage-stgops.mt.mtnet",
forwardedPort: "443",
forwardedProto: "https",
expected: "storage-stgops.mt.mtnet:30007",
},
{
name: "X-Forwarded-Host already contains correct port, ignore misaligned X-Forwarded-Port",
hostHeader: "backend:8333",
forwardedHost: "storage-stgops.mt.mtnet:30007",
forwardedPort: "443",
forwardedProto: "https",
expected: "storage-stgops.mt.mtnet:30007",
},
{
name: "X-Forwarded-Host has no port, match r.Host hostname and take its port",
hostHeader: "example.com:8080",
forwardedHost: "example.com",
forwardedPort: "80",
forwardedProto: "http",
expected: "example.com:8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock request
req, err := http.NewRequest("GET", "http://"+tt.hostHeader+"/bucket/object", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Set headers
req.Host = tt.hostHeader
if tt.forwardedHost != "" {
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
}
if tt.forwardedPort != "" {
req.Header.Set("X-Forwarded-Port", tt.forwardedPort)
}
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
// Test the function
result := extractHostHeader(req, tt.externalHost)
if result != tt.expected {
t.Errorf("extractHostHeader() = %q, want %q", result, tt.expected)
}
})
}
}
// TestExtractHostHeaderCandidates tests the alternate host values tried during verification
// when the true client-facing host is ambiguous behind a reverse proxy.
func TestExtractHostHeaderCandidates(t *testing.T) {
tests := []struct {
name string
hostHeader string
forwardedHost string
forwardedPort string
forwardedProto string
externalHost string
expected []string
}{
{
name: "externalHost leads, request host still follows",
hostHeader: "backend:8333",
externalHost: "api.example.com:9000",
expected: []string{"api.example.com:9000", "backend:8333"},
},
{
name: "externalHost leads the forwarded candidates",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "9000",
externalHost: "api.example.com",
expected: []string{"api.example.com", "example.com:9000", "example.com"},
},
{
name: "externalHost equal to the request host is not repeated",
hostHeader: "api.example.com:9000",
externalHost: "api.example.com:9000",
expected: []string{"api.example.com:9000"},
},
{
name: "X-Forwarded-Host with port is trusted as-is",
hostHeader: "backend:8333",
forwardedHost: "example.com:9000",
forwardedPort: "443",
expected: []string{"example.com:9000"},
},
{
name: "plain Host with port is the only candidate",
hostHeader: "example.com:8080",
expected: []string{"example.com:8080"},
},
{
name: "portless X-Forwarded-Host, hostnames match: r.Host port first, then X-Forwarded-Port, then bare",
hostHeader: "example.com:8333",
forwardedHost: "example.com",
forwardedPort: "9000",
forwardedProto: "http",
expected: []string{"example.com:8333", "example.com:9000", "example.com"},
},
{
name: "portless X-Forwarded-Host, hostnames match, no X-Forwarded-Port: bare host as fallback",
hostHeader: "example.com:8333",
forwardedHost: "example.com",
expected: []string{"example.com:8333", "example.com"},
},
{
name: "portless X-Forwarded-Host, hostnames differ: X-Forwarded-Port, then bare",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "9000",
expected: []string{"example.com:9000", "example.com"},
},
{
name: "default X-Forwarded-Port collapses into the bare candidate",
hostHeader: "example.com:8333",
forwardedHost: "example.com",
forwardedPort: "443",
forwardedProto: "https",
expected: []string{"example.com:8333", "example.com"},
},
{
name: "portless Host with X-Forwarded-Port: forwarded port, then bare",
hostHeader: "example.com",
forwardedPort: "9000",
expected: []string{"example.com:9000", "example.com"},
},
{
name: "bracketed portless IPv6 X-Forwarded-Host matches the request host",
hostHeader: "[::1]:8333",
forwardedHost: "[::1]",
expected: []string{"[::1]:8333", "::1"},
},
{
name: "unbracketed portless IPv6 X-Forwarded-Host with X-Forwarded-Port",
hostHeader: "backend:8333",
forwardedHost: "::1",
forwardedPort: "8080",
expected: []string{"[::1]:8080", "::1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("GET", "http://"+tt.hostHeader+"/bucket/object", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Host = tt.hostHeader
if tt.forwardedHost != "" {
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
}
if tt.forwardedPort != "" {
req.Header.Set("X-Forwarded-Port", tt.forwardedPort)
}
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
result := extractHostHeaderCandidates(req, tt.externalHost)
if !slices.Equal(result, tt.expected) {
t.Errorf("extractHostHeaderCandidates() = %v, want %v", result, tt.expected)
}
})
}
}
func TestExtractSignedHeadersCase(t *testing.T) {
tests := []struct {
name string
host string
signedHeads []string
expected string
}{
{
name: "lowercase host",
host: "bucket.domain.com:442",
signedHeads: []string{"host"},
expected: "host:bucket.domain.com:442\n",
},
{
name: "uppercase Host",
host: "bucket.domain.com:442",
signedHeads: []string{"Host"},
expected: "host:bucket.domain.com:442\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest("GET", "http://"+tt.host+"/", nil)
r.Host = tt.host
extracted, errCode := extractSignedHeaders(tt.signedHeads, r, "")
if errCode != s3err.ErrNone {
t.Fatalf("extractSignedHeaders failed: %v", errCode)
}
actual := getCanonicalHeaders(extracted)
if actual != tt.expected {
t.Errorf("%s: expected %q, got %q", tt.name, tt.expected, actual)
}
})
}
}

View File

@@ -0,0 +1,295 @@
package s3api
import (
"fmt"
"net/http"
"sort"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// TestVerifySignedHeadersCoverage_Unit exercises the helper directly.
func TestVerifySignedHeadersCoverage_Unit(t *testing.T) {
tests := []struct {
name string
headers map[string]string
signedHeaders []string
isPresigned bool
want s3err.ErrorCode
}{
{
name: "no x-amz headers is fine",
headers: map[string]string{"Content-Type": "text/plain"},
signedHeaders: []string{"host"},
want: s3err.ErrNone,
},
{
name: "signed x-amz header is accepted",
headers: map[string]string{
"X-Amz-Date": "20250101T000000Z",
"X-Amz-Tagging": "a=b",
},
signedHeaders: []string{"host", "x-amz-date", "x-amz-tagging"},
want: s3err.ErrNone,
},
{
name: "unsigned x-amz-tagging is rejected (header-based)",
headers: map[string]string{
"X-Amz-Date": "20250101T000000Z",
"X-Amz-Tagging": "secret=pwn",
},
signedHeaders: []string{"host", "x-amz-date"},
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "unsigned x-amz-meta-* is rejected",
headers: map[string]string{
"X-Amz-Meta-Owner": "attacker",
},
signedHeaders: []string{"host"},
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "unsigned x-amz-acl is rejected",
headers: map[string]string{
"X-Amz-Acl": "public-read",
},
signedHeaders: []string{"host"},
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "unsigned x-amz-storage-class is rejected",
headers: map[string]string{
"X-Amz-Storage-Class": "GLACIER",
},
signedHeaders: []string{"host"},
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "unsigned x-amz-server-side-encryption is rejected",
headers: map[string]string{
"X-Amz-Server-Side-Encryption": "AES256",
},
signedHeaders: []string{"host"},
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "unsigned x-amz-object-lock-retain-until-date is rejected",
headers: map[string]string{
"X-Amz-Object-Lock-Retain-Until-Date": "2099-01-01T00:00:00Z",
},
signedHeaders: []string{"host"},
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "unsigned x-amz-security-token is rejected (even presigned)",
headers: map[string]string{
"X-Amz-Security-Token": "attacker-token",
},
signedHeaders: []string{"host"},
isPresigned: true,
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "x-amz-content-sha256 is always exempt (presigned)",
headers: map[string]string{
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
},
signedHeaders: []string{"host"},
isPresigned: true,
want: s3err.ErrNone,
},
{
name: "x-amz-content-sha256 is always exempt (header-based)",
headers: map[string]string{
"X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
signedHeaders: []string{"host"},
isPresigned: false,
want: s3err.ErrNone,
},
{
name: "presigned exempts sigv4 query params echoed as headers",
headers: map[string]string{
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "AKIA/20250101/us-east-1/s3/aws4_request",
"X-Amz-Date": "20250101T000000Z",
"X-Amz-Expires": "3600",
"X-Amz-SignedHeaders": "host",
"X-Amz-Signature": "deadbeef",
},
signedHeaders: []string{"host"},
isPresigned: true,
want: s3err.ErrNone,
},
{
name: "header-based does NOT exempt x-amz-date when unsigned",
headers: map[string]string{
"X-Amz-Date": "20250101T000000Z",
},
signedHeaders: []string{"host"},
isPresigned: false,
want: s3err.ErrSignatureDoesNotMatch,
},
{
name: "signed headers match is case-insensitive",
headers: map[string]string{
"X-Amz-Tagging": "a=b",
},
signedHeaders: []string{"HOST", "X-Amz-Tagging"},
want: s3err.ErrNone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, "http://example.com/bucket/key", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
for k, v := range tt.headers {
req.Header.Set(k, v)
}
got := verifySignedHeadersCoverage(req, tt.signedHeaders, tt.isPresigned)
if got != tt.want {
t.Fatalf("verifySignedHeadersCoverage = %v, want %v", got, tt.want)
}
})
}
}
// TestPresignedPutRejectsUnsignedTagging exercises the full verification path
// through reqSignatureV4Verify for a presigned URL that signs only `host` but
// whose caller attached an x-amz-tagging header after-the-fact.
func TestPresignedPutRejectsUnsignedTagging(t *testing.T) {
iam := newTestIAM()
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", 0, nil)
if err != nil {
t.Fatalf("newTestRequest: %v", err)
}
if err := preSignV4(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600); err != nil {
t.Fatalf("preSignV4: %v", err)
}
// Attacker appends an unsigned header after the URL was signed.
req.Header.Set("X-Amz-Tagging", "classification=public")
_, errCode := iam.reqSignatureV4Verify(req)
if errCode != s3err.ErrSignatureDoesNotMatch {
t.Fatalf("expected ErrSignatureDoesNotMatch for unsigned x-amz-tagging, got %v", errCode)
}
}
// TestPresignedPutAcceptsSignedTagging confirms that a presigned URL whose
// SignedHeaders list covers x-amz-tagging still validates successfully.
func TestPresignedPutAcceptsSignedTagging(t *testing.T) {
iam := newTestIAM()
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", 0, nil)
if err != nil {
t.Fatalf("newTestRequest: %v", err)
}
req.Header.Set("X-Amz-Tagging", "classification=public")
if err := preSignV4WithHeaders(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600, []string{"host", "x-amz-tagging"}); err != nil {
t.Fatalf("preSignV4WithHeaders: %v", err)
}
_, errCode := iam.reqSignatureV4Verify(req)
if errCode != s3err.ErrNone {
t.Fatalf("expected ErrNone for signed x-amz-tagging, got %v", errCode)
}
}
// preSignV4WithHeaders is a test helper that builds a presigned URL whose
// SignedHeaders list covers the specified headers (which must already be set
// on the request), then computes the signature over those headers and
// attaches X-Amz-Signature.
func preSignV4WithHeaders(_ *IdentityAccessManagement, req *http.Request, accessKey, secretKey string, expires int64, signedHeaders []string) error {
now := time.Now().UTC()
dateStr := now.Format(iso8601Format)
scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
credential := fmt.Sprintf("%s/%s", accessKey, scope)
normalized := make([]string, 0, len(signedHeaders))
for _, h := range signedHeaders {
normalized = append(normalized, strings.ToLower(h))
}
sort.Strings(normalized)
query := req.URL.Query()
query.Set("X-Amz-Algorithm", signV4Algorithm)
query.Set("X-Amz-Credential", credential)
query.Set("X-Amz-Date", dateStr)
query.Set("X-Amz-Expires", fmt.Sprintf("%d", expires))
query.Set("X-Amz-SignedHeaders", strings.Join(normalized, ";"))
req.URL.RawQuery = query.Encode()
hashedPayload := query.Get("X-Amz-Content-Sha256")
if hashedPayload == "" {
hashedPayload = unsignedPayload
}
extracted := make(http.Header)
for _, h := range normalized {
if h == "host" {
extracted[h] = []string{req.Host}
continue
}
if values, ok := req.Header[http.CanonicalHeaderKey(h)]; ok {
extracted[h] = values
}
}
canonicalRequest := getCanonicalRequest(extracted, hashedPayload, req.URL.RawQuery, req.URL.Path, req.Method)
stringToSign := getStringToSign(canonicalRequest, now, scope)
signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
signature := getSignature(signingKey, stringToSign)
query.Set("X-Amz-Signature", signature)
req.URL.RawQuery = query.Encode()
return nil
}
// TestPresignedPutRejectsUnsignedMetadataHeaders checks a spread of
// security-relevant headers that the PUT handler persists.
func TestPresignedPutRejectsUnsignedMetadataHeaders(t *testing.T) {
dangerous := []struct {
name string
header string
value string
}{
{"acl", "X-Amz-Acl", "public-read"},
{"user-metadata", "X-Amz-Meta-Owner", "attacker"},
{"storage-class", "X-Amz-Storage-Class", "GLACIER"},
{"server-side-encryption", "X-Amz-Server-Side-Encryption", "AES256"},
{"sse-kms-key-id", "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id", "kms-key"},
{"object-lock-mode", "X-Amz-Object-Lock-Mode", "GOVERNANCE"},
{"object-lock-retain-until", "X-Amz-Object-Lock-Retain-Until-Date", "2099-01-01T00:00:00Z"},
{"website-redirect-location", "X-Amz-Website-Redirect-Location", "https://attacker.example/"},
{"grant-full-control", "X-Amz-Grant-Full-Control", "id=attacker"},
}
for _, c := range dangerous {
t.Run(c.name, func(t *testing.T) {
iam := newTestIAM()
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", 0, nil)
if err != nil {
t.Fatalf("newTestRequest: %v", err)
}
if err := preSignV4(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600); err != nil {
t.Fatalf("preSignV4: %v", err)
}
req.Header.Set(c.header, c.value)
_, errCode := iam.reqSignatureV4Verify(req)
if errCode != s3err.ErrSignatureDoesNotMatch {
t.Fatalf("expected rejection for unsigned %s, got %v", c.header, errCode)
}
})
}
}

View File

@@ -0,0 +1,396 @@
package s3api
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// TestSTSIdentityPolicyNamesPopulation tests that STS identities have PolicyNames
// populated from sessionInfo.Policies, enabling IAM-based authorization.
// This is a regression test for GitHub issue #7985.
func TestSTSIdentityPolicyNamesPopulation(t *testing.T) {
// Setup: Create a mock STS service
stsService, config := setupTestSTSService(t)
// Create IAM with STS integration
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
s3iam := &S3IAMIntegration{
stsService: stsService,
}
iam.SetIAMIntegration(s3iam)
// Generate a session token with policies
sessionId := "test-session-id"
expiresAt := time.Now().Add(time.Hour)
// Create session claims with policies
sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt).
WithSessionName("test-session").
WithRoleInfo("arn:aws:iam::role/adminRole", "arn:aws:sts::assumed-role/adminRole/test-session", "arn:aws:sts::assumed-role/adminRole/test-session")
// Add policies to the session claims
sessionClaims.Policies = []string{"AdminPolicy", "S3FullAccess"}
// Generate JWT token
tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer)
sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims)
require.NoError(t, err, "Should generate session token successfully")
// Validate the session token
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken)
require.NoError(t, err, "Session token should be valid")
require.NotNil(t, sessionInfo, "Session info should not be nil")
// Verify that sessionInfo has policies
assert.NotEmpty(t, sessionInfo.Policies, "SessionInfo should have policies")
assert.Equal(t, []string{"AdminPolicy", "S3FullAccess"}, sessionInfo.Policies, "Policies should match")
// Create a mock credential from session info
cred := &Credential{
AccessKey: sessionInfo.Credentials.AccessKeyId,
SecretKey: sessionInfo.Credentials.SecretAccessKey,
Status: "Active",
Expiration: sessionInfo.ExpiresAt.Unix(),
}
// Create identity as validateSTSSessionToken does
identity := &Identity{
Name: sessionInfo.AssumedRoleUser,
Account: &AccountAdmin,
Credentials: []*Credential{cred},
PrincipalArn: sessionInfo.Principal,
PolicyNames: sessionInfo.Policies, // This is the fix for issue #7985
}
// Verify the fix: PolicyNames should be populated
assert.NotNil(t, identity.PolicyNames, "Identity PolicyNames should not be nil")
assert.Equal(t, []string{"AdminPolicy", "S3FullAccess"}, identity.PolicyNames, "PolicyNames should be populated from sessionInfo.Policies")
// Verify that Actions is empty (STS identities should use IAM authorization, not legacy Actions)
assert.Empty(t, identity.Actions, "STS identities should have empty Actions to trigger IAM authorization path")
// Verify legacy CanDo returns false (forcing fallback to IAM)
assert.False(t, identity.CanDo("Read", "test-bucket", "/any/path"),
"CanDo should return false for STS identities with no Actions")
// Verify authorization path selection
// When identity.Actions is empty and iamIntegration is available, it should use IAM authorization
hasActions := len(identity.Actions) > 0
hasIAMIntegration := iam.iamIntegration != nil
assert.False(t, hasActions, "STS identity should not have Actions")
assert.True(t, hasIAMIntegration, "IAM integration should be available")
// This combination means authorization will go through the IAM path
t.Log("✓ STS identity will use IAM authorization path (correct behavior)")
}
// TestSTSIdentityAuthorizationFlow tests the complete authorization flow for STS identities
// to ensure they are properly authorized via IAM integration.
func TestSTSIdentityAuthorizationFlow(t *testing.T) {
// This test validates that:
// 1. STS identities have empty Actions
// 2. STS identities have PolicyNames populated
// 3. Authorization logic routes to IAM path when Actions is empty and iamIntegration exists
// Create a mock STS session info
sessionInfo := &sts.SessionInfo{
SessionId: "test-session",
SessionName: "s3-session",
RoleArn: "arn:aws:iam::role/adminRole",
AssumedRoleUser: "arn:aws:sts::assumed-role/adminRole/s3-session",
Principal: "arn:aws:sts::assumed-role/adminRole/s3-session",
Policies: []string{"AdminPolicy", "S3WritePolicy"},
ExpiresAt: time.Now().Add(time.Hour),
Credentials: &sts.Credentials{
AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
SessionToken: "test-session-token",
Expiration: time.Now().Add(time.Hour),
},
}
// Create credential from session info
cred := &Credential{
AccessKey: sessionInfo.Credentials.AccessKeyId,
SecretKey: sessionInfo.Credentials.SecretAccessKey,
Status: "Active",
Expiration: sessionInfo.ExpiresAt.Unix(),
}
// Create identity as validateSTSSessionToken does (with the fix)
identity := &Identity{
Name: sessionInfo.AssumedRoleUser,
Account: &AccountAdmin,
Credentials: []*Credential{cred},
PrincipalArn: sessionInfo.Principal,
PolicyNames: sessionInfo.Policies, // Fix for #7985
}
// Test 1: Verify PolicyNames are populated
assert.Equal(t, []string{"AdminPolicy", "S3WritePolicy"}, identity.PolicyNames,
"PolicyNames should be populated from sessionInfo.Policies")
assert.Empty(t, identity.Actions,
"STS identities should have empty Actions to trigger the IAM authorization path")
// Test 2: Verify CanDo returns false (legacy auth should be bypassed)
// This is important because it confirms that identity.Actions being empty
// correctly forces the authorization logic to fall back to iam.authorizeWithIAM
assert.False(t, identity.CanDo("Read", "test-bucket", "/any/path"),
"CanDo should return false for STS identities with no Actions")
// With empty Actions and populated PolicyNames, IAM authorization path will be used
// as per auth_credentials.go:703-713
t.Log("✓ Verified: STS identity correctly bypasses legacy CanDo() to use IAM authorization path")
}
// TestSTSIdentityWithoutPolicyNames tests the bug scenario where PolicyNames is not populated
// This reproduces the original issue #7985
func TestSTSIdentityWithoutPolicyNames(t *testing.T) {
// Create identity WITHOUT PolicyNames (the bug scenario)
identityBuggy := newTestIdentity(nil)
// Create identity WITH PolicyNames (the fix)
identityFixed := newTestIdentity([]string{"AdminPolicy"})
// Verify the bug scenario
assert.Empty(t, identityBuggy.Actions, "Buggy identity has no Actions")
assert.Empty(t, identityBuggy.PolicyNames, "Buggy identity has no PolicyNames (the bug)")
// Verify the fix
assert.Empty(t, identityFixed.Actions, "Fixed identity has no Actions (correct)")
assert.NotEmpty(t, identityFixed.PolicyNames, "Fixed identity has PolicyNames (the fix)")
t.Logf("Bug scenario: Actions=%d, PolicyNames=%d → Would be denied",
len(identityBuggy.Actions), len(identityBuggy.PolicyNames))
t.Logf("Fixed scenario: Actions=%d, PolicyNames=%d → Can use IAM authorization",
len(identityFixed.Actions), len(identityFixed.PolicyNames))
}
// TestCanDoPathConstruction tests that bucket and objectKey are properly concatenated
// with a slash separator. This is a regression test for the second issue mentioned in #7985.
func TestCanDoPathConstruction(t *testing.T) {
// Create a test identity with wildcard actions (standard pattern)
identity := &Identity{
Name: "test-user",
Account: &AccountAdmin,
Actions: []Action{
"Write:test-bucket/*", // Wildcard for all objects
"Read:test-bucket/docs/*", // Wildcard for specific path
},
}
// Test cases for path construction
testCases := []struct {
name string
action Action
bucket string
objectKey string
shouldPass bool
}{
{
name: "Wildcard match for write",
action: "Write",
bucket: "test-bucket",
objectKey: "path/to/object.txt",
shouldPass: true,
},
{
name: "Wildcard match for read in docs path",
action: "Read",
bucket: "test-bucket",
objectKey: "docs/file.txt",
shouldPass: true,
},
{
name: "No match - wrong action",
action: "Delete",
bucket: "test-bucket",
objectKey: "file.txt",
shouldPass: false,
},
{
name: "No match - read outside docs path",
action: "Read",
bucket: "test-bucket",
objectKey: "other/file.txt",
shouldPass: false,
},
{
name: "ObjectKey with leading slash",
action: "Read",
bucket: "test-bucket",
objectKey: "/docs/file.txt", // Already has leading slash
shouldPass: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := identity.CanDo(tc.action, tc.bucket, tc.objectKey)
// Robust path construction for verification
fullPath := tc.bucket
if tc.objectKey != "" && !strings.HasPrefix(tc.objectKey, "/") {
fullPath += "/"
}
fullPath += tc.objectKey
t.Logf("Testing path: %s", fullPath)
if tc.shouldPass {
assert.True(t, result, "Should allow action %s on %s", tc.action, fullPath)
} else {
assert.False(t, result, "Should deny action %s on %s", tc.action, fullPath)
}
})
}
}
// TestValidateSTSSessionTokenIntegration is an integration test that validates
// the complete flow from session token validation to identity creation
func TestValidateSTSSessionTokenIntegration(t *testing.T) {
// Setup STS service
stsService, config := setupTestSTSService(t)
// Create IAM with STS integration
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
s3iam := &S3IAMIntegration{
stsService: stsService,
}
iam.SetIAMIntegration(s3iam)
// Create a mock HTTP request with STS session token
req, err := http.NewRequest("PUT", "/test-bucket/test-object.txt", nil)
require.NoError(t, err)
// Generate session token
sessionId := "integration-test-session"
expiresAt := time.Now().Add(time.Hour)
sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt).
WithSessionName("integration-test").
WithRoleInfo("arn:aws:iam::role/testRole", "arn:aws:sts::assumed-role/testRole/integration-test", "arn:aws:sts::assumed-role/testRole/integration-test")
sessionClaims.Policies = []string{"TestPolicy"}
tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer)
sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims)
require.NoError(t, err)
// Validate session token
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken)
require.NoError(t, err)
require.NotNil(t, sessionInfo)
// Validate session token and check identity creation
identity, _, errCode := iam.validateSTSSessionToken(req, sessionToken, sessionInfo.Credentials.AccessKeyId)
require.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity)
// Verify the identity is properly configured for IAM authorization
assert.Empty(t, identity.Actions, "STS identity should have empty Actions")
assert.Equal(t, []string{"TestPolicy"}, identity.PolicyNames, "PolicyNames should be populated (this requires the fix in #7985)")
assert.NotEmpty(t, identity.PrincipalArn, "PrincipalArn should be set")
t.Log("✓ Integration test passed: STS identity properly configured for IAM authorization")
}
// TestSTSIdentityClaimsPopulation tests that Claims are properly populated from RequestContext
// This is critical for policy variable substitution like ${jwt:preferred_username}
func TestSTSIdentityClaimsPopulation(t *testing.T) {
// Setup STS service
stsService, config := setupTestSTSService(t)
// Create IAM with STS integration
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
s3iam := &S3IAMIntegration{
stsService: stsService,
}
iam.SetIAMIntegration(s3iam)
// Create a mock HTTP request
req, err := http.NewRequest("PUT", "/test-bucket/test-object.txt", nil)
require.NoError(t, err)
// Generate session token with RequestContext containing user claims
sessionId := "claims-test-session"
expiresAt := time.Now().Add(time.Hour)
sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt).
WithSessionName("claims-test").
WithRoleInfo("arn:aws:iam::role/S3UserRole", "arn:aws:sts::assumed-role/S3UserRole/claims-test", "arn:aws:sts::assumed-role/S3UserRole/claims-test")
// Add RequestContext with user claims (simulating AssumeRoleWithWebIdentity)
sessionClaims.RequestContext = map[string]interface{}{
"preferred_username": "f2wbnp",
"email": "user@example.com",
"name": "Test User",
"groups": []string{"developers", "users"},
}
sessionClaims.Policies = []string{"S3UserPolicy"}
tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer)
sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims)
require.NoError(t, err)
// Validate session token
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken)
require.NoError(t, err)
require.NotNil(t, sessionInfo)
require.NotNil(t, sessionInfo.RequestContext, "RequestContext should be populated")
// Verify RequestContext has the claims
assert.Equal(t, "f2wbnp", sessionInfo.RequestContext["preferred_username"])
assert.Equal(t, "user@example.com", sessionInfo.RequestContext["email"])
// Validate session token and check identity creation
identity, _, errCode := iam.validateSTSSessionToken(req, sessionToken, sessionInfo.Credentials.AccessKeyId)
require.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity)
// Verify Claims are populated from RequestContext
assert.NotNil(t, identity.Claims, "Claims should be populated")
assert.Equal(t, "f2wbnp", identity.Claims["preferred_username"], "preferred_username should be in Claims")
assert.Equal(t, "user@example.com", identity.Claims["email"], "email should be in Claims")
assert.Equal(t, "Test User", identity.Claims["name"], "name should be in Claims")
// Verify PolicyNames are also populated
assert.Equal(t, []string{"S3UserPolicy"}, identity.PolicyNames)
t.Log("✓ Claims properly populated from RequestContext for policy variable substitution")
}
// Helper functions for tests
func setupTestSTSService(t *testing.T) (*sts.STSService, *sts.STSConfig) {
t.Helper()
stsService := sts.NewSTSService()
config := &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-issuer",
SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"),
}
err := stsService.Initialize(config)
require.NoError(t, err, "STS service should initialize successfully")
return stsService, config
}
func newTestIdentity(policyNames []string) *Identity {
return &Identity{
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
Account: &AccountAdmin,
Credentials: []*Credential{{AccessKey: "test", SecretKey: "test"}},
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
PolicyNames: policyNames,
}
}

View File

@@ -0,0 +1,149 @@
package s3api
import (
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// TestAuthorizeWithIAMSessionTokenExtraction tests that the authorizeWithIAM function
// correctly extracts session tokens from multiple sources and prioritizes them appropriately.
// This is a regression test for the bug where X-Amz-Security-Token was not being checked
// for V4 signature authentication with STS credentials.
func TestAuthorizeWithIAMSessionTokenExtraction(t *testing.T) {
t.Run("Extracts X-SeaweedFS-Session-Token from JWT auth", func(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Seaweedfs-Session-Token": {"jwt-token-123"},
"X-Seaweedfs-Principal": {"arn:aws:iam::user/test"},
},
URL: &url.URL{},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
principal := req.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
assert.Equal(t, "jwt-token-123", sessionToken, "Should extract JWT session token from header")
assert.Equal(t, "arn:aws:iam::user/test", principal, "Should extract principal from header")
})
t.Run("Extracts X-Amz-Security-Token from V4 STS auth header", func(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Amz-Security-Token": {"sts-token-header-456"},
},
URL: &url.URL{},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
principal := req.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
// If JWT token is empty, should fallback to X-Amz-Security-Token
if sessionToken == "" {
sessionToken = req.Header.Get("X-Amz-Security-Token")
}
assert.Equal(t, "sts-token-header-456", sessionToken, "Should fallback to X-Amz-Security-Token when JWT token is empty")
assert.Empty(t, principal, "JWT principal should be empty for V4 auth")
})
t.Run("Extracts X-Amz-Security-Token from query parameter (presigned URL)", func(t *testing.T) {
req := &http.Request{
Header: http.Header{},
URL: &url.URL{RawQuery: "X-Amz-Security-Token=sts-token-query-789"},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
if sessionToken == "" {
sessionToken = req.Header.Get("X-Amz-Security-Token")
if sessionToken == "" {
sessionToken = req.URL.Query().Get("X-Amz-Security-Token")
}
}
assert.Equal(t, "sts-token-query-789", sessionToken, "Should extract token from query parameter")
})
t.Run("JWT token takes precedence over X-Amz-Security-Token", func(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Seaweedfs-Session-Token": {"jwt-preferred"},
"X-Seaweedfs-Principal": {"arn:aws:iam::user/jwt-user"},
"X-Amz-Security-Token": {"sts-fallback"},
},
URL: &url.URL{},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
if sessionToken == "" {
sessionToken = req.Header.Get("X-Amz-Security-Token")
}
assert.Equal(t, "jwt-preferred", sessionToken, "JWT token should take precedence")
})
}
// TestSTSSessionTokenIntoCredentials verifies that STS session tokens are properly
// preserved when converting to credentials for authorization.
func TestSTSSessionTokenIntoCredentials(t *testing.T) {
// Create a credential generator and session claims
credGen := sts.NewCredentialGenerator()
sessionId := "test-session-123"
expiresAt := time.Now().Add(time.Hour)
// Generate temporary credentials
creds, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt)
require.NoError(t, err, "Should generate credentials successfully")
require.NotNil(t, creds, "Credentials should not be nil")
// Verify all credential fields are present
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should be present")
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should be present")
assert.NotEmpty(t, creds.SessionToken, "SessionToken should be present for STS")
// Verify deterministic generation (same session ID produces same credentials)
creds2, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt)
require.NoError(t, err)
assert.Equal(t, creds.AccessKeyId, creds2.AccessKeyId, "AccessKeyId should be deterministic")
assert.Equal(t, creds.SecretAccessKey, creds2.SecretAccessKey, "SecretAccessKey should be deterministic")
assert.Equal(t, creds.SessionToken, creds2.SessionToken, "SessionToken should be deterministic for same sessionId")
// Verify different session produces different credentials
creds3, err := credGen.GenerateTemporaryCredentials("different-session", expiresAt)
require.NoError(t, err)
assert.NotEqual(t, creds.AccessKeyId, creds3.AccessKeyId, "Different sessions should produce different access key IDs")
assert.NotEqual(t, creds.SecretAccessKey, creds3.SecretAccessKey, "Different sessions should produce different secret keys")
assert.NotEqual(t, creds.SessionToken, creds3.SessionToken, "Different sessions should produce different session tokens")
}
// TestActionConstantsForV4Auth verifies that action constants are properly available
// for use in authorization checks with V4 signature authentication.
func TestActionConstantsForV4Auth(t *testing.T) {
// Verify that S3 action constants are available
actions := map[string]string{
"READ": s3_constants.ACTION_READ,
"WRITE": s3_constants.ACTION_WRITE,
"READ_ACP": s3_constants.ACTION_READ_ACP,
"WRITE_ACP": s3_constants.ACTION_WRITE_ACP,
"LIST": s3_constants.ACTION_LIST,
"TAGGING": s3_constants.ACTION_TAGGING,
"ADMIN": s3_constants.ACTION_ADMIN,
}
for name, action := range actions {
assert.NotEmpty(t, action, "Action %s should not be empty", name)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
package s3api
import (
"encoding/json"
"github.com/aws/aws-sdk-go/service/s3"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"golang.org/x/sync/singleflight"
)
var loadBucketMetadataFromFiler = func(r *BucketRegistry, bucketName string) (*BucketMetaData, error) {
entry, err := r.s3a.getBucketEntry(bucketName)
if err != nil {
return nil, err
}
return buildBucketMetadata(r.s3a.iam, entry), nil
}
type BucketMetaData struct {
_ struct{} `type:"structure"`
Name string
// Indicates the bucket is a table bucket.
IsTableBucket bool
//By default, when another AWS account uploads an object to S3 bucket,
//that account (the object writer) owns the object, has access to it, and
//can grant other users access to it through ACLs. You can use Object Ownership
//to change this default behavior so that ACLs are disabled and you, as the
//bucket owner, automatically own every object in your bucket.
ObjectOwnership string
// Container for the bucket owner's display name and ID.
Owner *s3.Owner `type:"structure"`
// A list of grants for access controls.
Acl []*s3.Grant `locationName:"AccessControlList" locationNameList:"Grant" type:"list"`
}
type BucketRegistry struct {
metadataCache *lru.Cache[string, *BucketMetaData]
notFound *lru.Cache[string, struct{}]
// loadGroup deduplicates concurrent filer loads of the same bucket
// without serializing loads of different buckets
loadGroup singleflight.Group
s3a *S3ApiServer
}
// NewBucketRegistry creates a lazy registry: nothing is listed at startup,
// buckets load from the filer on first access and stay fresh via the
// metadata subscription.
func NewBucketRegistry(s3a *S3ApiServer) *BucketRegistry {
metadataCache, _ := lru.New[string, *BucketMetaData](bucketCacheCapacity)
notFound, _ := lru.New[string, struct{}](bucketCacheCapacity)
return &BucketRegistry{
metadataCache: metadataCache,
notFound: notFound,
s3a: s3a,
}
}
// LoadBucketMetadata refreshes a bucket already resident in the cache from a
// subscription event. Cold buckets are left to lazy-load on first access so
// the cache holds only this gateway's working set.
func (r *BucketRegistry) LoadBucketMetadata(entry *filer_pb.Entry) {
if r.metadataCache.Contains(entry.Name) {
r.metadataCache.Add(entry.Name, buildBucketMetadata(r.s3a.iam, entry))
}
// Remove from notFound cache since bucket now exists
r.unMarkNotFound(entry.Name)
}
// bucketOwnerAccountId returns the account id owning the bucket entry. A bucket
// created outside the S3 API (the admin UI, weed shell) records only its owning
// identity, so that identity is resolved to its account. Without this such a
// bucket looks unowned: it reports the default admin account as its ACL owner,
// and under BucketOwnerEnforced every object written to it is stamped with that
// account instead of the bucket owner.
func bucketOwnerAccountId(accountManager AccountManager, entry *filer_pb.Entry) string {
if ownerAccountId := string(entry.Extended[s3_constants.ExtAmzOwnerKey]); ownerAccountId != "" {
return ownerAccountId
}
if identityName := string(entry.Extended[s3_constants.AmzIdentityId]); identityName != "" {
return accountManager.GetAccountIdByIdentityName(identityName)
}
return ""
}
func buildBucketMetadata(accountManager AccountManager, entry *filer_pb.Entry) *BucketMetaData {
entryJson, _ := json.Marshal(entry)
glog.V(3).Infof("build bucket metadata,entry=%s", entryJson)
bucketMetadata := &BucketMetaData{
Name: entry.Name,
IsTableBucket: s3tables.IsTableBucketEntry(entry),
//Default ownership: OwnershipBucketOwnerEnforced, which means Acl is disabled
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
// Default owner: `AccountAdmin`
Owner: &s3.Owner{
ID: &AccountAdmin.Id,
DisplayName: &AccountAdmin.DisplayName,
},
}
if entry.Extended != nil {
//ownership control
if ownership, ok := entry.Extended[s3_constants.ExtOwnershipKey]; ok {
if !s3_constants.ValidateOwnership(string(ownership)) {
glog.Warningf("Invalid ownership: %s, bucket: %s", string(ownership), bucketMetadata.Name)
}
bucketMetadata.ObjectOwnership = s3_constants.EffectiveOwnership(string(ownership))
}
//access control policy
//owner
ownerAccountId := bucketOwnerAccountId(accountManager, entry)
if ownerAccountId != "" {
ownerAccountName := accountManager.GetAccountNameById(ownerAccountId)
if ownerAccountName == "" {
glog.Warningf("owner[id=%s] is invalid, bucket: %s", ownerAccountId, bucketMetadata.Name)
} else {
bucketMetadata.Owner = &s3.Owner{
ID: &ownerAccountId,
DisplayName: &ownerAccountName,
}
}
}
//grants
acpGrantsBytes, ok := entry.Extended[s3_constants.ExtAmzAclKey]
if ok && len(acpGrantsBytes) > 0 {
var grants []*s3.Grant
err := json.Unmarshal(acpGrantsBytes, &grants)
if err == nil {
bucketMetadata.Acl = grants
} else {
glog.Warningf("Unmarshal ACP grants: %s(%v), bucket: %s", string(acpGrantsBytes), err, bucketMetadata.Name)
}
}
}
return bucketMetadata
}
func (r *BucketRegistry) RemoveBucketMetadata(entry *filer_pb.Entry) {
r.removeMetadataCache(entry.Name)
r.unMarkNotFound(entry.Name)
}
func (r *BucketRegistry) GetBucketMetadata(bucketName string) (*BucketMetaData, s3err.ErrorCode) {
bucketMetadata, ok := r.metadataCache.Get(bucketName)
if ok {
return bucketMetadata, s3err.ErrNone
}
if r.notFound.Contains(bucketName) {
return nil, s3err.ErrNoSuchBucket
}
return r.LoadBucketMetadataFromFiler(bucketName)
}
// LoadBucketMetadataFromFiler loads the bucket from the filer; concurrent
// calls for the same bucket share one load, and the cache is filled inside
// the flight so a bucket is fetched only once.
func (r *BucketRegistry) LoadBucketMetadataFromFiler(bucketName string) (*BucketMetaData, s3err.ErrorCode) {
metadata, err, _ := r.loadGroup.Do(bucketName, func() (interface{}, error) {
//check if already exists
if bucketMetaData, ok := r.metadataCache.Get(bucketName); ok {
return bucketMetaData, nil
}
//if not exists, load from filer
bucketMetadata, err := loadBucketMetadataFromFiler(r, bucketName)
if err != nil {
if err == filer_pb.ErrNotFound {
// The bucket doesn't actually exist and should no longer loaded from the filer
r.notFound.Add(bucketName, struct{}{})
}
return nil, err
}
r.setMetadataCache(bucketMetadata)
r.unMarkNotFound(bucketName)
return bucketMetadata, nil
})
if err != nil {
if err == filer_pb.ErrNotFound {
return nil, s3err.ErrNoSuchBucket
}
return nil, s3err.ErrInternalError
}
return metadata.(*BucketMetaData), s3err.ErrNone
}
func (r *BucketRegistry) setMetadataCache(metadata *BucketMetaData) {
r.metadataCache.Add(metadata.Name, metadata)
}
func (r *BucketRegistry) removeMetadataCache(bucket string) {
r.metadataCache.Remove(bucket)
}
func (r *BucketRegistry) unMarkNotFound(bucket string) {
r.notFound.Remove(bucket)
}

View File

@@ -0,0 +1,227 @@
package s3api
import (
"encoding/json"
"fmt"
"reflect"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
type BucketMetadataTestCase struct {
filerEntry *filer_pb.Entry
expectBucketMetadata *BucketMetaData
}
var (
//bad entry
badEntry = &filer_pb.Entry{
Name: "badEntry",
}
//good entry
goodEntryAcl, _ = json.Marshal(s3_constants.PublicRead)
goodEntry = &filer_pb.Entry{
Name: "entryWithValidAcp",
Extended: map[string][]byte{
s3_constants.ExtOwnershipKey: []byte(s3_constants.OwnershipBucketOwnerEnforced),
s3_constants.ExtAmzOwnerKey: []byte(AccountAdmin.DisplayName),
s3_constants.ExtAmzAclKey: goodEntryAcl,
},
}
//ownership is ""
ownershipEmptyStr = &filer_pb.Entry{
Name: "ownershipEmptyStr",
Extended: map[string][]byte{
s3_constants.ExtOwnershipKey: []byte(""),
},
}
//ownership valid
ownershipValid = &filer_pb.Entry{
Name: "ownershipValid",
Extended: map[string][]byte{
s3_constants.ExtOwnershipKey: []byte(s3_constants.OwnershipBucketOwnerEnforced),
},
}
//owner is ""
acpEmptyStr = &filer_pb.Entry{
Name: "acpEmptyStr",
Extended: map[string][]byte{
s3_constants.ExtAmzOwnerKey: []byte(""),
},
}
//owner not exists
acpEmptyObject = &filer_pb.Entry{
Name: "acpEmptyObject",
Extended: map[string][]byte{
s3_constants.ExtAmzOwnerKey: []byte("xxxxx"),
},
}
//grants is nil
acpOwnerNilAcp, _ = json.Marshal(make([]*s3.Grant, 0))
acpOwnerNil = &filer_pb.Entry{
Name: "acpOwnerNil",
Extended: map[string][]byte{
s3_constants.ExtAmzAclKey: acpOwnerNilAcp,
},
}
//load filer is
loadFilerBucket = make(map[string]int, 1)
loadFilerBucketLock sync.Mutex
//override `loadBucketMetadataFromFiler` to avoid really load from filer
)
var tcs = []*BucketMetadataTestCase{
{
badEntry, &BucketMetaData{
Name: badEntry.Name,
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: nil,
},
},
{
goodEntry, &BucketMetaData{
Name: goodEntry.Name,
ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: s3_constants.PublicRead,
},
},
{
ownershipEmptyStr, &BucketMetaData{
Name: ownershipEmptyStr.Name,
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: nil,
},
},
{
ownershipValid, &BucketMetaData{
Name: ownershipValid.Name,
ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: nil,
},
},
{
acpEmptyStr, &BucketMetaData{
Name: acpEmptyStr.Name,
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: nil,
},
},
{
acpEmptyObject, &BucketMetaData{
Name: acpEmptyObject.Name,
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: nil,
},
},
{
acpOwnerNil, &BucketMetaData{
Name: acpOwnerNil.Name,
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
Owner: &s3.Owner{
DisplayName: &AccountAdmin.DisplayName,
ID: &AccountAdmin.Id,
},
Acl: make([]*s3.Grant, 0),
},
},
}
func TestBuildBucketMetadata(t *testing.T) {
iam := &IdentityAccessManagement{}
_ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{})
for _, tc := range tcs {
resultBucketMetadata := buildBucketMetadata(iam, tc.filerEntry)
if !reflect.DeepEqual(resultBucketMetadata, tc.expectBucketMetadata) {
t.Fatalf("result is unexpected: \nresult: %v, \nexpect: %v", resultBucketMetadata, tc.expectBucketMetadata)
}
}
}
func TestGetBucketMetadata(t *testing.T) {
loadBucketMetadataFromFiler = func(r *BucketRegistry, bucketName string) (*BucketMetaData, error) {
time.Sleep(time.Second)
loadFilerBucketLock.Lock()
loadFilerBucket[bucketName] = loadFilerBucket[bucketName] + 1
loadFilerBucketLock.Unlock()
return &BucketMetaData{
Name: bucketName,
}, nil
}
br := NewBucketRegistry(nil)
//start 40 goroutine for
var wg sync.WaitGroup
closeCh := make(chan struct{})
for i := 0; i < 40; i++ {
wg.Add(1)
go func() {
defer wg.Done()
outLoop:
for {
for j := 0; j < 5; j++ {
select {
case <-closeCh:
break outLoop
default:
reqBucket := fmt.Sprintf("%c", 67+j)
_, errCode := br.GetBucketMetadata(reqBucket)
if errCode != s3err.ErrNone {
close(closeCh)
t.Error("not expect")
}
}
}
time.Sleep(10 * time.Microsecond)
}
}()
}
time.Sleep(time.Second)
close(closeCh)
wg.Wait()
//Each bucket is loaded from the filer only once
for bucketName, loadCount := range loadFilerBucket {
if loadCount != 1 {
t.Fatalf("lock is uneffict: %s, %d", bucketName, loadCount)
}
}
}

View File

@@ -0,0 +1,86 @@
package s3api
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ownerIdentityIAM loads an account-less identity and one scoped to an explicit
// account, the two shapes a bucket owner name can resolve through.
func ownerIdentityIAM(t *testing.T) *IdentityAccessManagement {
t.Helper()
iam := &IdentityAccessManagement{}
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "test", Actions: []string{"Read", "Write"}},
{
Name: "scoped",
Actions: []string{"Read", "Write"},
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Scoped"},
},
},
}))
return iam
}
// A bucket created outside the S3 API — the admin UI or weed shell, which record
// the owner as an identity and never write the account id the S3 API stores — is
// owned by that identity, not by the default admin account. Getting this wrong
// stamps every object uploaded to the bucket with the admin account, since the
// default BucketOwnerEnforced ownership hands objects to the bucket owner.
func TestBucketOwnerFromIdentityId(t *testing.T) {
iam := ownerIdentityIAM(t)
for _, tc := range []struct {
name string
identityId string
expectOwnerId string
}{
{"account-less identity", "test", "test"},
{"identity scoped to an account", "scoped", "100000000001"},
{"unknown identity", "ghost", AccountAdmin.Id},
} {
t.Run(tc.name, func(t *testing.T) {
entry := &filer_pb.Entry{
Name: "bucket",
Extended: map[string][]byte{s3_constants.AmzIdentityId: []byte(tc.identityId)},
}
metadata := buildBucketMetadata(iam, entry)
require.NotNil(t, metadata.Owner)
assert.Equal(t, tc.expectOwnerId, *metadata.Owner.ID)
s3a := &S3ApiServer{iam: iam}
config := s3a.newBucketConfigFromEntry("bucket", entry)
assert.Equal(t, tc.identityId, config.IdentityId)
if tc.expectOwnerId != AccountAdmin.Id {
assert.Equal(t, tc.expectOwnerId, config.Owner,
"the bucket config owner must match the metadata owner")
} else {
assert.Empty(t, config.Owner, "an unresolvable owner leaves the config unowned")
}
})
}
}
// The account id recorded by the S3 API stays authoritative when both are present.
func TestBucketOwnerPrefersAccountId(t *testing.T) {
iam := ownerIdentityIAM(t)
entry := &filer_pb.Entry{
Name: "bucket",
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("scoped"),
s3_constants.ExtAmzOwnerKey: []byte("test"),
},
}
metadata := buildBucketMetadata(iam, entry)
require.NotNil(t, metadata.Owner)
assert.Equal(t, "test", *metadata.Owner.ID)
}

108
weed/s3api/bucket_paths.go Normal file
View File

@@ -0,0 +1,108 @@
package s3api
import (
"errors"
"path"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
var tableBucketFileValidator = s3tables.NewTableBucketFileValidator()
func (s3a *S3ApiServer) isTableBucket(bucket string) bool {
if bucket == "" {
return false
}
if s3a.bucketRegistry != nil {
metadata, errCode := s3a.bucketRegistry.GetBucketMetadata(bucket)
if errCode != s3err.ErrNone {
if errCode != s3err.ErrNoSuchBucket {
glog.V(1).Infof("bucket lookup failed for %s: %v", bucket, errCode)
}
return false
}
return metadata.IsTableBucket
}
entry, err := s3a.getEntry(s3a.option.BucketsPath, bucket)
if err == nil && entry != nil {
return s3tables.IsTableBucketEntry(entry)
}
if err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
glog.V(1).Infof("bucket lookup failed for %s: %v", bucket, err)
}
return false
}
func (s3a *S3ApiServer) bucketRoot(bucket string) string {
// Returns the unified buckets root path for all bucket types
return s3a.option.BucketsPath
}
func (s3a *S3ApiServer) bucketDir(bucket string) string {
return path.Join(s3a.bucketRoot(bucket), bucket)
}
func (s3a *S3ApiServer) validateTableBucketObjectPath(bucket, object string) error {
if !s3a.isTableBucket(bucket) {
return nil
}
cleanObject := strings.TrimPrefix(object, "/")
if cleanObject == "" {
return &s3tables.IcebergLayoutError{
Code: s3tables.ErrCodeInvalidIcebergLayout,
Message: "object must be under namespace/table/data or metadata",
}
}
fullPath := s3a.bucketDir(bucket)
if !strings.HasSuffix(fullPath, "/") {
fullPath += "/"
}
fullPath += cleanObject
if err := tableBucketFileValidator.ValidateTableBucketUpload(fullPath); err != nil {
return err
}
parts := strings.SplitN(cleanObject, "/", 4)
// A table's marker files sit at namespace/table/<marker>, one level above
// everything else, so they are three parts rather than four.
if len(parts) == 3 && s3tables.IsTableMarkerFile(parts[2]) {
return nil
}
if len(parts) < 4 {
return &s3tables.IcebergLayoutError{
Code: s3tables.ErrCodeInvalidIcebergLayout,
Message: "object must be under namespace/table/data or metadata",
}
}
return nil
}
func (s3a *S3ApiServer) bucketPrefix(bucket string) string {
return s3a.bucketDir(bucket) + "/"
}
func (s3a *S3ApiServer) bucketExists(bucket string) (bool, error) {
entry, err := s3a.getBucketEntry(bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return false, nil
}
return false, err
}
return entry != nil, nil
}
func (s3a *S3ApiServer) getBucketEntry(bucket string) (*filer_pb.Entry, error) {
// Dot-prefixed entries under /buckets are internal (.system) and can never
// be valid bucket names; don't let them resolve as buckets.
if strings.HasPrefix(bucket, ".") {
return nil, filer_pb.ErrNotFound
}
return s3a.getEntry(s3a.option.BucketsPath, bucket)
}

View File

@@ -0,0 +1,261 @@
package s3api
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
)
const (
bucketSizeMetricsInterval = 1 * time.Minute
listBucketPageSize = 1000 // Page size for paginated bucket listing
s3MetricsLockName = "s3.leader"
)
// CollectionInfo holds collection statistics
// Used for both metrics collection and quota enforcement
type CollectionInfo struct {
FileCount float64
DeleteCount float64
DeletedByteCount float64
Size float64 // Single-copy volume size (deduplicated by volume ID), still counting un-vacuumed garbage
PhysicalSize float64 // Physical size (including all replicas)
VolumeCount int // Logical volume count (deduplicated by volume ID)
}
// LogicalSize is the live data size: single-copy volume size minus the
// un-vacuumed deleted/overwritten bytes. Quota enforcement and the
// bucket_size_bytes metric use this so vacuum lag never counts against a
// bucket, matching the usage figure the Admin UI shows.
func (c *CollectionInfo) LogicalSize() float64 {
if c.Size < c.DeletedByteCount {
return 0
}
return c.Size - c.DeletedByteCount
}
// startBucketSizeMetricsLoop periodically collects bucket size metrics and updates Prometheus gauges.
// Uses a distributed lock to ensure only one S3 instance collects metrics at a time.
// Should be called as a goroutine; stops when the provided context is cancelled.
func (s3a *S3ApiServer) startBucketSizeMetricsLoop(ctx context.Context) {
// Initial delay to let the system stabilize
select {
case <-time.After(10 * time.Second):
case <-ctx.Done():
return
}
// Create lock client for distributed lock
if len(s3a.option.Filers) == 0 {
glog.V(1).Infof("No filers configured, skipping bucket size metrics collection")
return
}
filer := s3a.option.Filers[0]
lockClient := cluster.NewLockClient(s3a.option.GrpcDialOption, filer)
owner := string(filer) + "-s3-metrics"
// Start long-lived lock - this S3 instance will only collect metrics when it holds the lock
lock := lockClient.StartLongLivedLock(s3MetricsLockName, owner, func(newLockOwner string) {
glog.V(1).Infof("S3 bucket size metrics lock owner changed to: %s", newLockOwner)
}, bucketSizeMetricsInterval)
defer lock.Stop()
ticker := time.NewTicker(bucketSizeMetricsInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
glog.V(1).Infof("Stopping bucket size metrics collection")
return
case <-ticker.C:
// Only collect metrics if we hold the lock
if lock.IsLocked() {
s3a.collectAndUpdateBucketSizeMetrics(ctx)
}
}
}
}
// collectAndUpdateBucketSizeMetrics collects bucket sizes from master topology
// and updates Prometheus metrics. Uses the same approach as quota enforcement.
func (s3a *S3ApiServer) collectAndUpdateBucketSizeMetrics(ctx context.Context) {
// Collect collection info from master topology (same as quota enforcement)
collectionInfos, err := s3a.collectCollectionInfoFromMaster(ctx)
if err != nil {
glog.V(2).Infof("Failed to collect collection info from master: %v", err)
return
}
// Get list of buckets
buckets, err := s3a.listBuckets(ctx)
if err != nil {
glog.V(2).Infof("Failed to list buckets for size metrics: %v", err)
return
}
// Map collections to buckets and update metrics
for _, bucket := range buckets {
collection := s3a.getCollectionName(bucket.Name)
if info, found := collectionInfos[collection]; found {
stats.UpdateBucketSizeMetrics(bucket.Name, info.LogicalSize(), info.PhysicalSize, info.FileCount)
glog.V(3).Infof("Updated bucket size metrics: bucket=%s, logicalSize=%.0f, physicalSize=%.0f, objects=%.0f",
bucket.Name, info.LogicalSize(), info.PhysicalSize, info.FileCount)
} else {
// Bucket exists but no collection data (empty bucket)
stats.UpdateBucketSizeMetrics(bucket.Name, 0, 0, 0)
}
}
s3a.enforceBucketQuotas(ctx, buckets, collectionInfos)
}
// enforceBucketQuotas flips each bucket's read-only flag to match its quota,
// rewriting filer.conf only when a flag changes.
func (s3a *S3ApiServer) enforceBucketQuotas(ctx context.Context, buckets []*filer_pb.Entry, collectionInfos map[string]*CollectionInfo) {
if len(s3a.option.Filers) == 0 {
return
}
fc, err := filer.ReadFilerConfFromFilers(s3a.option.Filers, s3a.option.GrpcDialOption, nil)
if err != nil {
glog.V(1).Infof("read filer.conf for quota enforcement: %v", err)
return
}
changed := false
for _, bucket := range buckets {
var size float64
if info, found := collectionInfos[s3a.getCollectionName(bucket.Name)]; found {
size = info.LogicalSize()
}
locPrefix := s3a.option.BucketsPath + "/" + bucket.Name + "/"
readOnly, flipped := fc.ApplyBucketQuotaReadOnly(locPrefix, size, float64(bucket.Quota))
stats.UpdateBucketQuotaMetrics(bucket.Name, float64(bucket.Quota), readOnly)
if flipped {
changed = true
glog.V(0).Infof("bucket %s quota enforcement: readOnly=%v (size=%.0f quota=%d)", bucket.Name, readOnly, size, bucket.Quota)
}
}
if !changed {
return
}
var buf bytes.Buffer
if err := fc.ToText(&buf); err != nil {
glog.Errorf("serialize filer.conf for quota enforcement: %v", err)
return
}
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer.SaveInsideFiler(ctx, client, filer.DirectoryEtcSeaweedFS, filer.FilerConfName, buf.Bytes())
}); err != nil {
glog.Errorf("save filer.conf for quota enforcement: %v", err)
}
}
// collectCollectionInfoFromMaster queries the master for topology info and extracts collection sizes.
// This is the same approach used by shell command s3.bucket.quota.enforce.
func (s3a *S3ApiServer) collectCollectionInfoFromMaster(ctx context.Context) (map[string]*CollectionInfo, error) {
if len(s3a.option.Masters) == 0 {
return nil, fmt.Errorf("no masters configured")
}
// Convert masters slice to map for WithOneOfGrpcMasterClients
masterMap := make(map[string]pb.ServerAddress)
for _, master := range s3a.option.Masters {
masterMap[string(master)] = master
}
// Ask the master to summarise. Adding this up here instead would mean
// being sent every volume in the cluster once a minute.
collectionInfos := make(map[string]*CollectionInfo)
err := pb.WithOneOfGrpcMasterClients(false, masterMap, s3a.option.GrpcDialOption, func(client master_pb.SeaweedClient) error {
resp, err := client.CollectionStatistics(ctx, &master_pb.CollectionStatisticsRequest{})
if err != nil {
return fmt.Errorf("failed to get collection statistics: %w", err)
}
if resp == nil {
return fmt.Errorf("empty collection statistics from master")
}
for _, c := range resp.Collections {
collectionInfos[c.Collection] = &CollectionInfo{
FileCount: float64(c.FileCount),
DeleteCount: float64(c.DeleteCount),
DeletedByteCount: float64(c.DeletedByteCount),
Size: float64(c.Size),
PhysicalSize: float64(c.PhysicalSize),
VolumeCount: int(c.VolumeCount),
}
}
return nil
})
if err != nil {
return nil, err
}
return collectionInfos, nil
}
// listBuckets returns all bucket directory entries using pagination.
func (s3a *S3ApiServer) listBuckets(ctx context.Context) ([]*filer_pb.Entry, error) {
var buckets []*filer_pb.Entry
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
lastFileName := ""
for {
request := &filer_pb.ListEntriesRequest{
Directory: s3a.option.BucketsPath,
StartFromFileName: lastFileName,
Limit: listBucketPageSize,
InclusiveStartFrom: lastFileName == "",
}
stream, err := client.ListEntries(ctx, request)
if err != nil {
return err
}
entriesReceived := 0
for {
resp, err := stream.Recv()
if err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("error receiving bucket list entries: %w", err)
}
entriesReceived++
if resp.Entry != nil {
lastFileName = resp.Entry.Name
if resp.Entry.IsDirectory {
// Skip .uploads and other hidden directories
if !strings.HasPrefix(resp.Entry.Name, ".") {
buckets = append(buckets, resp.Entry)
}
}
}
}
// If we got fewer entries than the limit, we're done
if entriesReceived < listBucketPageSize {
break
}
}
return nil
})
return buckets, err
}

View File

@@ -0,0 +1,31 @@
package s3api
import (
"testing"
)
// TestCollectionInfoLogicalSize verifies logical size excludes un-vacuumed
// garbage and never goes negative. Quota enforcement runs on this value so a
// bucket full of tombstones is not flipped read-only while its live data is
// under quota.
func TestCollectionInfoLogicalSize(t *testing.T) {
cases := []struct {
name string
size float64
deleted float64
expected float64
}{
{"no garbage", 1000, 0, 1000},
{"some garbage", 1000, 300, 700},
{"all garbage", 1000, 1000, 0},
{"deleted exceeds size clamps to zero", 300, 1000, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
info := &CollectionInfo{Size: tc.size, DeletedByteCount: tc.deleted}
if got := info.LogicalSize(); got != tc.expected {
t.Errorf("LogicalSize(): got %.0f, want %.0f", got, tc.expected)
}
})
}
}

View File

@@ -0,0 +1,55 @@
package s3api
import (
"bytes"
"io"
"net/http"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// TestChunkedEncodingMixedFormat tests the fix for GitHub issue #6847
// where AWS SDKs send mixed format: unsigned streaming headers but signed chunk data
func TestChunkedEncodingMixedFormat(t *testing.T) {
expectedContent := "hello world\n"
// Create the problematic mixed format payload:
// - Unsigned streaming headers (STREAMING-UNSIGNED-PAYLOAD-TRAILER)
// - But chunk data contains chunk-signature headers
mixedFormatPayload := "c;chunk-signature=347f6c62acd95b7c6ae18648776024a9e8cd6151184a5e777ea8e1d9b4e45b3c\r\n" +
"hello world\n\r\n" +
"0;chunk-signature=1a99b7790b8db0f4bfc048c8802056c3179d561e40c073167e79db5f1a6af4b2\r\n" +
"x-amz-checksum-crc32:rwg7LQ==\r\n" +
"\r\n"
// Create HTTP request with unsigned streaming headers
req, _ := http.NewRequest("PUT", "/test-bucket/test-object", bytes.NewReader([]byte(mixedFormatPayload)))
req.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
// Process through SeaweedFS chunked reader
iam := setupTestIAM()
reader, errCode := iam.newChunkedReader(req)
if errCode != s3err.ErrNone {
t.Fatalf("Failed to create chunked reader: %v", errCode)
}
// Read the content
actualContent, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Failed to read content: %v", err)
}
// Should correctly extract just the content, ignoring chunk signatures
if string(actualContent) != expectedContent {
t.Errorf("Mixed format handling failed. Expected: %q, Got: %q", expectedContent, string(actualContent))
}
}
// setupTestIAM creates a test IAM instance using the same pattern as existing tests
func setupTestIAM() *IdentityAccessManagement {
iam := &IdentityAccessManagement{}
return iam
}

View File

@@ -0,0 +1,620 @@
package s3api
// the related code is copied and modified from minio source code
/*
* Minio Cloud Storage, (C) 2016 Minio, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import (
"bufio"
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash"
"hash/crc32"
"io"
"net/http"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/dustin/go-humanize"
"github.com/minio/crc64nvme"
)
// calculateSeedSignature - Calculate seed signature in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
//
// returns signature, error otherwise if the signature mismatches or any other
// error while parsing and validating.
func (iam *IdentityAccessManagement) calculateSeedSignature(r *http.Request) (cred *Credential, signature string, region string, service string, date time.Time, errCode s3err.ErrorCode) {
_, credential, calculatedSignature, authInfo, errCode := iam.verifyV4Signature(r, true)
if errCode != s3err.ErrNone {
return nil, "", "", "", time.Time{}, errCode
}
// This check ensures we only proceed for streaming uploads.
switch authInfo.HashedPayload {
case streamingContentSHA256, streamingContentSHA256Trailer:
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", authInfo.HashedPayload == streamingContentSHA256Trailer)
case streamingUnsignedPayload:
glog.V(3).Infof("streaming unsigned payload")
default:
return nil, "", "", "", time.Time{}, s3err.ErrContentSHA256Mismatch
}
return credential, calculatedSignature, authInfo.Region, authInfo.Service, authInfo.Date, s3err.ErrNone
}
const maxLineLength = 4 * humanize.KiByte // assumed <= bufio.defaultBufSize 4KiB
// lineTooLong is generated as chunk header is bigger than 4KiB.
var errLineTooLong = errors.New("header line too long")
// Malformed encoding is generated when chunk header is wrongly formed.
var errMalformedEncoding = errors.New("malformed chunked encoding")
// newChunkedReader returns a new s3ChunkedReader that translates the data read from r
// out of HTTP "chunked" format before returning it.
// The s3ChunkedReader returns io.EOF when the final 0-length chunk is read.
func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.ReadCloser, s3err.ErrorCode) {
glog.V(3).Infof("creating a new newSignV4ChunkedReader")
contentSha256Header := req.Header.Get("X-Amz-Content-Sha256")
var credential *Credential
var seedSignature, region, service string
var seedDate time.Time
var errCode s3err.ErrorCode
switch contentSha256Header {
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' or 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
case streamingContentSHA256, streamingContentSHA256Trailer:
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", contentSha256Header == streamingContentSHA256Trailer)
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
if errCode != s3err.ErrNone {
return nil, errCode
}
case streamingUnsignedPayload:
glog.V(3).Infof("streaming unsigned payload")
// The seed signature is a SigV4 concept; recompute it only for SigV4-signed
// requests. JWT-bearer, presigned, and anonymous unsigned-streaming uploads
// carry no header seed, and verifyV4Signature would fail parsing them.
if isRequestSignatureV4(req) {
// We do not need to pass the seed signature to the Reader as each chunk is not signed,
// but we do compute it to verify the caller has the correct permissions.
_, _, _, _, _, errCode = iam.calculateSeedSignature(req)
if errCode != s3err.ErrNone {
return nil, errCode
}
}
}
// Get the checksum algorithm from the x-amz-trailer Header.
amzTrailerHeader := req.Header.Get("x-amz-trailer")
checksumAlgorithm, err := extractChecksumAlgorithm(amzTrailerHeader)
if err != nil {
glog.V(3).Infof("error extracting checksum algorithm: %v", err)
return nil, s3err.ErrInvalidRequest
}
checkSumWriter := getCheckSumWriter(checksumAlgorithm)
hasTrailer := amzTrailerHeader != ""
return &s3ChunkedReader{
cred: credential,
reader: bufio.NewReader(req.Body),
seedSignature: seedSignature,
seedDate: seedDate,
region: region,
service: service,
chunkSHA256Writer: sha256.New(),
checkSumAlgorithm: checksumAlgorithm.String(),
checkSumWriter: checkSumWriter,
state: readChunkHeader,
iam: iam,
hasTrailer: hasTrailer,
}, s3err.ErrNone
}
func extractChecksumAlgorithm(amzTrailerHeader string) (ChecksumAlgorithm, error) {
// Extract checksum algorithm from the x-amz-trailer header.
amzTrailerHeader = strings.ToLower(strings.TrimSpace(amzTrailerHeader))
switch amzTrailerHeader {
case "x-amz-checksum-crc32":
return ChecksumAlgorithmCRC32, nil
case "x-amz-checksum-crc32c":
return ChecksumAlgorithmCRC32C, nil
case "x-amz-checksum-crc64nvme":
return ChecksumAlgorithmCRC64NVMe, nil
case "x-amz-checksum-sha1":
return ChecksumAlgorithmSHA1, nil
case "x-amz-checksum-sha256":
return ChecksumAlgorithmSHA256, nil
case "":
return ChecksumAlgorithmNone, nil
default:
return ChecksumAlgorithmNone, errors.New("unsupported checksum algorithm '" + amzTrailerHeader + "'")
}
}
// Represents the overall state that is required for decoding a
// AWS Signature V4 chunked reader.
type s3ChunkedReader struct {
cred *Credential
reader *bufio.Reader
seedSignature string
seedDate time.Time
region string
service string // Service from credential scope (e.g., "s3", "iam")
state chunkState
lastChunk bool
chunkSignature string // Empty string if unsigned streaming upload.
checkSumAlgorithm string // Empty string if no checksum algorithm is specified.
checkSumWriter hash.Hash
chunkSHA256Writer hash.Hash // Calculates sha256 of chunk data.
n uint64 // Unread bytes in chunk
err error
iam *IdentityAccessManagement
hasTrailer bool
}
// Read chunk reads the chunk token signature portion.
func (cr *s3ChunkedReader) readS3ChunkHeader() {
// Read the first chunk line until CRLF.
var bytesRead, hexChunkSize, hexChunkSignature []byte
bytesRead, cr.err = readChunkLine(cr.reader)
// Parse s3 specific chunk extension and fetch the values.
hexChunkSize, hexChunkSignature = parseS3ChunkExtension(bytesRead)
if cr.err != nil {
return
}
// <hex>;token=value - converts the hex into its uint64 form.
cr.n, cr.err = parseHexUint(hexChunkSize)
if cr.err != nil {
return
}
if cr.n == 0 {
cr.err = io.EOF
}
// Save the incoming chunk signature.
if hexChunkSignature == nil {
// We are using unsigned streaming upload.
cr.chunkSignature = ""
} else {
cr.chunkSignature = string(hexChunkSignature)
}
}
type chunkState int
const (
readChunkHeader chunkState = iota
readChunkTrailer
readChunk
readTrailerChunk
verifyChunk
verifyChecksum
eofChunk
)
func (cs chunkState) String() string {
stateString := ""
switch cs {
case readChunkHeader:
stateString = "readChunkHeader"
case readChunkTrailer:
stateString = "readChunkTrailer"
case readChunk:
stateString = "readChunk"
case readTrailerChunk:
stateString = "readTrailerChunk"
case verifyChunk:
stateString = "verifyChunk"
case verifyChecksum:
stateString = "verifyChecksum"
case eofChunk:
stateString = "eofChunk"
}
return stateString
}
func (cr *s3ChunkedReader) Close() (err error) {
return nil
}
// Read - implements `io.Reader`, which transparently decodes
// the incoming AWS Signature V4 streaming signature.
func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
for {
switch cr.state {
case readChunkHeader:
cr.readS3ChunkHeader()
// If we're at the end of a chunk.
if cr.n == 0 && cr.err == io.EOF {
cr.state = readChunkTrailer
cr.lastChunk = true
continue
}
if cr.err != nil {
return 0, cr.err
}
cr.state = readChunk
case readChunkTrailer:
err = peekCRLF(cr.reader)
isTrailingChunk := cr.n == 0 && cr.lastChunk
if !isTrailingChunk {
// If we're not in the trailing chunk, we should consume the bytes no matter what.
// The error returned by peekCRLF is the same as the one by readCRLF.
readCRLF(cr.reader)
cr.err = err
} else if err != nil && err != errMalformedEncoding {
cr.err = err
return 0, errMalformedEncoding
} else { // equivalent to isTrailingChunk && err == errMalformedEncoding
// FIXME: The "right" structure of the last chunk as provided by the examples in the
// AWS documentation is "0\r\n\r\n" instead of "0\r\n", but some s3 clients when calling with
// streaming-unsigned-payload-trailer omit the last CRLF. To avoid returning an error that, we need to accept both.
// We arrive here when we're at the end of the 0-byte chunk, depending on the client implementation
// the client may or may not send the optional CRLF after the 0-byte chunk.
// If the client sends the optional CRLF, we should consume it.
if err == nil {
readCRLF(cr.reader)
}
}
// If we're using unsigned streaming upload, there is no signature to verify at each chunk.
if cr.lastChunk && cr.hasTrailer {
cr.state = readTrailerChunk
} else if cr.chunkSignature != "" {
cr.state = verifyChunk
} else {
cr.state = readChunkHeader
}
case readTrailerChunk:
// When using unsigned upload, this would be the raw contents of the trailer chunk:
//
// x-amz-checksum-crc32:YABb/g==\n\r\n\r\n // Trailer chunk (note optional \n character)
// \r\n // CRLF
//
// When using signed upload with an additional checksum algorithm, this would be the raw contents of the trailer chunk:
//
// x-amz-checksum-crc32:YABb/g==\n\r\n // Trailer chunk (note optional \n character)
// trailer-signature\r\n
// \r\n // CRLF
//
// This implementation currently only supports the first case.
// TODO: Implement the second case (signed upload with additional checksum computation for each chunk)
extractedCheckSumAlgorithm, extractedChecksum, err := parseChunkChecksum(cr.reader)
if err != nil {
cr.err = err
return 0, err
}
if extractedCheckSumAlgorithm.String() != cr.checkSumAlgorithm {
errorMessage := fmt.Sprintf("checksum algorithm in trailer '%s' does not match the one advertised in the header '%s'", extractedCheckSumAlgorithm.String(), cr.checkSumAlgorithm)
glog.V(3).Info(errorMessage)
cr.err = errors.New(s3err.ErrMsgChecksumAlgorithmMismatch)
return 0, cr.err
}
// Validate checksum for data integrity (required for both signed and unsigned streaming with trailers)
computedChecksum := cr.checkSumWriter.Sum(nil)
base64Checksum := base64.StdEncoding.EncodeToString(computedChecksum)
if string(extractedChecksum) != base64Checksum {
glog.V(3).Infof("payload checksum '%s' does not match provided checksum '%s'", base64Checksum, string(extractedChecksum))
cr.err = errors.New(s3err.ErrMsgPayloadChecksumMismatch)
return 0, cr.err
}
// TODO: Extract signature from trailer chunk and verify it.
// For now, we just read the trailer chunk and discard it.
cr.state = eofChunk
case readChunk:
// There is no more space left in the request buffer.
if len(buf) == 0 {
return n, nil
}
rbuf := buf
// The request buffer is larger than the current chunk size.
// Read only the current chunk from the underlying reader.
if uint64(len(rbuf)) > cr.n {
rbuf = rbuf[:cr.n]
}
var n0 int
n0, cr.err = cr.reader.Read(rbuf)
if cr.err != nil {
// We have lesser than chunk size advertised in chunkHeader, this is 'unexpected'.
if cr.err == io.EOF {
cr.err = io.ErrUnexpectedEOF
}
return 0, cr.err
}
// Calculate sha256.
cr.chunkSHA256Writer.Write(rbuf[:n0])
// Compute checksum
if cr.checkSumWriter != nil {
cr.checkSumWriter.Write(rbuf[:n0])
}
// Update the bytes read into request buffer so far.
n += n0
buf = buf[n0:]
// Update bytes to be read of the current chunk before verifying chunk's signature.
cr.n -= uint64(n0)
// If we're at the end of a chunk.
if cr.n == 0 {
cr.state = readChunkTrailer
}
case verifyChunk:
// Check if we have credentials for signature verification
// This handles the case where we have unsigned streaming (no cred) but chunks contain signatures
//
// BUG FIX for GitHub issue #6847:
// Some AWS SDK versions (Java 3.7.412+, .NET 4.0.0-preview.6+) send mixed format:
// - HTTP headers indicate unsigned streaming (STREAMING-UNSIGNED-PAYLOAD-TRAILER)
// - But chunk data contains chunk-signature headers (normally only for signed streaming)
// This causes a nil pointer dereference when trying to verify signatures without credentials
if cr.cred != nil {
// Normal signed streaming - verify the chunk signature
// Calculate the hashed chunk.
hashedChunk := hex.EncodeToString(cr.chunkSHA256Writer.Sum(nil))
// Calculate the chunk signature.
newSignature := cr.getChunkSignature(hashedChunk)
if !compareSignatureV4(cr.chunkSignature, newSignature) {
// Chunk signature doesn't match we return signature does not match.
cr.err = errors.New(s3err.ErrMsgChunkSignatureMismatch)
return 0, cr.err
}
// Newly calculated signature becomes the seed for the next chunk
// this follows the chaining.
cr.seedSignature = newSignature
} else {
// For unsigned streaming, we should not verify chunk signatures even if they are present
// This fixes the bug where AWS SDKs send chunk signatures with unsigned streaming headers
glog.V(3).Infof("Skipping chunk signature verification for unsigned streaming")
}
// Common cleanup and state transition for both signed and unsigned streaming
cr.chunkSHA256Writer.Reset()
if cr.lastChunk {
cr.state = eofChunk
} else {
cr.state = readChunkHeader
}
case eofChunk:
return n, io.EOF
}
}
}
// getChunkSignature - get chunk signature.
func (cr *s3ChunkedReader) getChunkSignature(hashedChunk string) string {
// Calculate string to sign.
stringToSign := signV4Algorithm + "-PAYLOAD" + "\n" +
cr.seedDate.Format(iso8601Format) + "\n" +
getScope(cr.seedDate, cr.region, cr.service) + "\n" +
cr.seedSignature + "\n" +
emptySHA256 + "\n" +
hashedChunk
// Get hmac signing key.
signingKey := getSigningKey(cr.cred.SecretKey, cr.seedDate.Format(yyyymmdd), cr.region, cr.service)
// Calculate and return signature.
return getSignature(signingKey, stringToSign)
}
func readCRLF(reader *bufio.Reader) error {
buf := make([]byte, 2)
_, err := io.ReadFull(reader, buf)
if err != nil {
return err
}
return checkCRLF(buf)
}
func peekCRLF(reader *bufio.Reader) error {
buf, err := reader.Peek(2)
if err != nil {
return err
}
if err := checkCRLF(buf); err != nil {
return err
}
return nil
}
func checkCRLF(buf []byte) error {
if len(buf) != 2 || buf[0] != '\r' || buf[1] != '\n' {
return errMalformedEncoding
}
return nil
}
func readChunkLine(b *bufio.Reader) ([]byte, error) {
buf, err := b.ReadSlice('\n')
if err != nil {
// We always know when EOF is coming.
// If the caller asked for a line, there should be a line.
switch err {
case io.EOF:
err = io.ErrUnexpectedEOF
case bufio.ErrBufferFull:
err = errLineTooLong
}
return nil, err
}
if len(buf) >= maxLineLength {
return nil, errLineTooLong
}
return trimTrailingWhitespace(buf), nil
}
// trimTrailingWhitespace - trim trailing white space.
func trimTrailingWhitespace(b []byte) []byte {
for len(b) > 0 && isASCIISpace(b[len(b)-1]) {
b = b[:len(b)-1]
}
return b
}
// isASCIISpace - is ascii space?
func isASCIISpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
// Constant s3 chunk encoding signature.
const s3ChunkSignatureStr = ";chunk-signature="
// parseS3ChunkExtension removes any s3 specific chunk-extension from buf.
// For example,
//
// "10000;chunk-signature=..." => "10000", "chunk-signature=..."
func parseS3ChunkExtension(buf []byte) ([]byte, []byte) {
buf = trimTrailingWhitespace(buf)
semi := bytes.Index(buf, []byte(s3ChunkSignatureStr))
// Chunk signature not found, return the whole buffer.
// This means we're using unsigned streaming upload.
if semi == -1 {
return buf, nil
}
return buf[:semi], parseChunkSignature(buf[semi:])
}
func parseChunkChecksum(b *bufio.Reader) (ChecksumAlgorithm, []byte, error) {
// Read trailer lines until empty line
var checksumAlgorithm ChecksumAlgorithm
var checksum []byte
for {
bytesRead, err := readChunkLine(b)
if err != nil {
return ChecksumAlgorithmNone, nil, err
}
if len(bytesRead) == 0 {
break
}
parts := bytes.SplitN(bytesRead, []byte(":"), 2)
if len(parts) == 2 {
key := string(bytes.TrimSpace(parts[0]))
value := bytes.TrimSpace(parts[1])
if alg, err := extractChecksumAlgorithm(key); err == nil {
if checksumAlgorithm != ChecksumAlgorithmNone {
glog.V(3).Infof("multiple checksum headers found in trailer, using last: %s", key)
}
checksumAlgorithm = alg
checksum = value
}
// Ignore other trailer headers like x-amz-trailer-signature
}
}
return checksumAlgorithm, checksum, nil
}
func parseChunkSignature(chunk []byte) []byte {
chunkSplits := bytes.SplitN(chunk, []byte("="), 2)
return chunkSplits[1] // Keep only the signature.
}
func parseHexUint(v []byte) (n uint64, err error) {
for i, b := range v {
switch {
case '0' <= b && b <= '9':
b = b - '0'
case 'a' <= b && b <= 'f':
b = b - 'a' + 10
case 'A' <= b && b <= 'F':
b = b - 'A' + 10
default:
return 0, errors.New("invalid byte in chunk length")
}
if i == 16 {
return 0, errors.New("http chunk length too large")
}
n <<= 4
n |= uint64(b)
}
return
}
// Checksum Algorithm represents the various checksum algorithms supported.
type ChecksumAlgorithm int
const (
ChecksumAlgorithmNone ChecksumAlgorithm = iota
ChecksumAlgorithmCRC32
ChecksumAlgorithmCRC32C
ChecksumAlgorithmCRC64NVMe
ChecksumAlgorithmSHA1
ChecksumAlgorithmSHA256
)
func (ca ChecksumAlgorithm) String() string {
switch ca {
case ChecksumAlgorithmNone:
return ""
case ChecksumAlgorithmCRC32:
return "x-amz-checksum-crc32"
case ChecksumAlgorithmCRC32C:
return "x-amz-checksum-crc32c"
case ChecksumAlgorithmCRC64NVMe:
return "x-amz-checksum-crc64nvme"
case ChecksumAlgorithmSHA1:
return "x-amz-checksum-sha1"
case ChecksumAlgorithmSHA256:
return "x-amz-checksum-sha256"
}
return ""
}
// getCheckSumWriter - get checksum writer.
func getCheckSumWriter(checksumAlgorithm ChecksumAlgorithm) hash.Hash {
switch checksumAlgorithm {
case ChecksumAlgorithmCRC32:
return crc32.NewIEEE()
case ChecksumAlgorithmCRC32C:
return crc32.New(crc32.MakeTable(crc32.Castagnoli))
case ChecksumAlgorithmCRC64NVMe:
return crc64nvme.New()
case ChecksumAlgorithmSHA1:
return sha1.New()
case ChecksumAlgorithmSHA256:
return sha256.New()
}
return nil
}

View File

@@ -0,0 +1,479 @@
package s3api
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"hash/crc32"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
)
// getDefaultTimestamp returns a current timestamp for tests
func getDefaultTimestamp() string {
return time.Now().UTC().Format(iso8601Format)
}
const (
defaultTimestamp = "20130524T000000Z" // Legacy constant for reference
defaultBucketName = "examplebucket"
defaultAccessKeyId = UserAccessKeyPrefix + "IOSFODNN7EXAMPLE"
defaultSecretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
defaultRegion = "us-east-1"
)
func TestExtractChecksumAlgorithmIsCaseInsensitive(t *testing.T) {
got, err := extractChecksumAlgorithm("X-Amz-Checksum-Crc32")
if err != nil {
t.Fatalf("extractChecksumAlgorithm returned error: %v", err)
}
if got != ChecksumAlgorithmCRC32 {
t.Fatalf("extractChecksumAlgorithm() = %v, want %v", got, ChecksumAlgorithmCRC32)
}
}
func generateStreamingUnsignedPayloadTrailerPayload(includeFinalCRLF bool) string {
// This test will implement the following scenario:
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
chunk1 := "2000\r\n" + strings.Repeat("a", 8192) + "\r\n"
chunk2 := "2000\r\n" + strings.Repeat("a", 8192) + "\r\n"
chunk3 := "400\r\n" + strings.Repeat("a", 1024) + "\r\n"
chunk4 := "0\r\n" /* the last chunk is empty */
if includeFinalCRLF {
// Some clients omit the final CRLF, so we need to test that case as well
chunk4 += "\r\n"
}
data := strings.Repeat("a", 17408)
writer := crc32.NewIEEE()
_, err := writer.Write([]byte(data))
if err != nil {
fmt.Println("Error:", err)
}
checksum := writer.Sum(nil)
base64EncodedChecksum := base64.StdEncoding.EncodeToString(checksum)
trailer := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\n\r\n\r\n\r\n"
payload := chunk1 + chunk2 + chunk3 + chunk4 + trailer
return payload
}
func NewRequestStreamingUnsignedPayloadTrailer(includeFinalCRLF bool) (*http.Request, error) {
// This test will implement the following scenario:
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
payload := generateStreamingUnsignedPayloadTrailerPayload(includeFinalCRLF)
req, err := http.NewRequest("PUT", "http://amzn-s3-demo-bucket/Key+", bytes.NewReader([]byte(payload)))
if err != nil {
return nil, err
}
req.Header.Set("Host", "amzn-s3-demo-bucket")
req.Header.Set("x-amz-date", getDefaultTimestamp())
req.Header.Set("Content-Encoding", "aws-chunked")
req.Header.Set("x-amz-decoded-content-length", "17408")
req.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
return req, nil
}
func TestNewSignV4ChunkedReaderStreamingUnsignedPayloadTrailer(t *testing.T) {
// This test will implement the following scenario:
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
iam := setupIam()
req, err := NewRequestStreamingUnsignedPayloadTrailer(true)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// The expected payload a long string of 'a's
expectedPayload := strings.Repeat("a", 17408)
runWithRequest(iam, req, t, expectedPayload)
req, err = NewRequestStreamingUnsignedPayloadTrailer(false)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
runWithRequest(iam, req, t, expectedPayload)
}
func runWithRequest(iam IdentityAccessManagement, req *http.Request, t *testing.T, expectedPayload string) {
reader, errCode := iam.newChunkedReader(req)
assert.NotNil(t, reader)
assert.Equal(t, s3err.ErrNone, errCode)
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Failed to read data: %v", err)
}
assert.Equal(t, expectedPayload, string(data))
}
func setupIam() IdentityAccessManagement {
// Create an IdentityAccessManagement instance
// Add default access keys and secrets
iam := IdentityAccessManagement{
identities: []*Identity{},
accessKeyIdent: map[string]*Identity{},
accounts: map[string]*Account{},
emailAccount: map[string]*Account{},
hashes: map[string]*sync.Pool{},
hashCounters: map[string]*int32{},
identityAnonymous: nil,
domain: "",
isAuthEnabled: false,
}
iam.identities = append(iam.identities, &Identity{
Name: "default",
Credentials: []*Credential{
{
AccessKey: defaultAccessKeyId,
SecretKey: defaultSecretAccessKey,
},
},
Actions: []Action{
"Read",
"Write",
"List",
},
})
iam.accessKeyIdent[defaultAccessKeyId] = iam.identities[0]
return iam
}
// TestSignedStreamingUpload tests streaming uploads with signed chunks
// This replaces the removed AWS example test with a dynamic signature generation approach
func TestSignedStreamingUpload(t *testing.T) {
iam := setupIam()
// Create a simple streaming upload with 2 chunks
chunk1Data := strings.Repeat("a", 1024)
chunk2Data := strings.Repeat("b", 512)
// Use current time for signatures
now := time.Now().UTC()
amzDate := now.Format(iso8601Format)
dateStamp := now.Format(yyyymmdd)
// Calculate seed signature
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
// Build canonical request for seed signature
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
canonicalHeaders := "content-encoding:aws-chunked\n" +
"host:s3.amazonaws.com\n" +
"x-amz-content-sha256:" + hashedPayload + "\n" +
"x-amz-date:" + amzDate + "\n" +
"x-amz-decoded-content-length:1536\n"
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length"
canonicalRequest := "PUT\n" +
"/test-bucket/test-object\n" +
"\n" +
canonicalHeaders + "\n" +
signedHeaders + "\n" +
hashedPayload
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
seedSignature := getSignature(signingKey, stringToSign)
// Calculate chunk signatures
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
chunk2Hash := getSHA256Hash([]byte(chunk2Data))
chunk2StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
chunk1Signature + "\n" + emptySHA256 + "\n" + chunk2Hash
chunk2Signature := getSignature(signingKey, chunk2StringToSign)
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
chunk2Signature + "\n" + emptySHA256 + "\n" + emptySHA256
finalSignature := getSignature(signingKey, finalStringToSign)
// Build the chunked payload
payload := fmt.Sprintf("400;chunk-signature=%s\r\n%s\r\n", chunk1Signature, chunk1Data) +
fmt.Sprintf("200;chunk-signature=%s\r\n%s\r\n", chunk2Signature, chunk2Data) +
fmt.Sprintf("0;chunk-signature=%s\r\n\r\n", finalSignature)
// Create the request
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
bytes.NewReader([]byte(payload)))
assert.NoError(t, err)
req.Header.Set("Host", "s3.amazonaws.com")
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", hashedPayload)
req.Header.Set("Content-Encoding", "aws-chunked")
req.Header.Set("x-amz-decoded-content-length", "1536")
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
defaultAccessKeyId, scope, signedHeaders, seedSignature)
req.Header.Set("Authorization", authHeader)
// Test the chunked reader
reader, errCode := iam.newChunkedReader(req)
assert.Equal(t, s3err.ErrNone, errCode)
assert.NotNil(t, reader)
// Read and verify the payload
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, chunk1Data+chunk2Data, string(data))
}
// createTrailerStreamingRequest creates a streaming upload request with trailer for testing.
// If useValidTrailerSignature is true, uses a correctly calculated trailer signature;
// otherwise uses an intentionally wrong signature for negative testing.
func createTrailerStreamingRequest(t *testing.T, useValidTrailerSignature bool) (*http.Request, string) {
chunk1Data := "hello world\n"
chunk1DataLen := len(chunk1Data)
chunk1DataLenHex := fmt.Sprintf("%x", chunk1DataLen)
// Use current time for signatures
now := time.Now().UTC()
amzDate := now.Format(iso8601Format)
dateStamp := now.Format(yyyymmdd)
// Calculate seed signature
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
// Build canonical request for seed signature
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"
canonicalHeaders := "content-encoding:aws-chunked\n" +
"host:s3.amazonaws.com\n" +
"x-amz-content-sha256:" + hashedPayload + "\n" +
"x-amz-date:" + amzDate + "\n" +
fmt.Sprintf("x-amz-decoded-content-length:%d\n", chunk1DataLen) +
"x-amz-trailer:x-amz-checksum-crc32\n"
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-trailer"
canonicalRequest := "PUT\n" +
"/test-bucket/test-object\n" +
"\n" +
canonicalHeaders + "\n" +
signedHeaders + "\n" +
hashedPayload
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
seedSignature := getSignature(signingKey, stringToSign)
// Calculate chunk signatures
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
// Final chunk (0 bytes)
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
chunk1Signature + "\n" + emptySHA256 + "\n" + emptySHA256
finalSignature := getSignature(signingKey, finalStringToSign)
// Calculate CRC32 checksum for trailer
crcWriter := crc32.NewIEEE()
_, crcErr := crcWriter.Write([]byte(chunk1Data))
assert.NoError(t, crcErr)
checksum := crcWriter.Sum(nil)
base64EncodedChecksum := base64.StdEncoding.EncodeToString(checksum)
// The on-wire trailer format uses \r\n (HTTP/aws-chunked convention)
trailerOnWire := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\r\n"
// Calculate or use wrong trailer signature
var trailerSignature string
if useValidTrailerSignature {
// The canonical trailer content uses \n for signing (per AWS SigV4 spec)
trailerCanonical := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\n"
trailerHash := getSHA256Hash([]byte(trailerCanonical))
trailerStringToSign := "AWS4-HMAC-SHA256-TRAILER\n" + amzDate + "\n" + scope + "\n" +
finalSignature + "\n" + trailerHash
trailerSignature = getSignature(signingKey, trailerStringToSign)
} else {
// Intentionally wrong signature for negative testing
trailerSignature = "0000000000000000000000000000000000000000000000000000000000000000"
}
// Build the chunked payload with trailer and trailer signature
payload := fmt.Sprintf("%s;chunk-signature=%s\r\n%s\r\n", chunk1DataLenHex, chunk1Signature, chunk1Data) +
fmt.Sprintf("0;chunk-signature=%s\r\n", finalSignature) +
trailerOnWire +
"x-amz-trailer-signature:" + trailerSignature + "\r\n" +
"\r\n"
// Create the request
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
bytes.NewReader([]byte(payload)))
assert.NoError(t, err)
req.Header.Set("Host", "s3.amazonaws.com")
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", hashedPayload)
req.Header.Set("Content-Encoding", "aws-chunked")
req.Header.Set("x-amz-decoded-content-length", fmt.Sprintf("%d", chunk1DataLen))
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
defaultAccessKeyId, scope, signedHeaders, seedSignature)
req.Header.Set("Authorization", authHeader)
return req, chunk1Data
}
// TestSignedStreamingUploadWithTrailer tests streaming uploads with signed chunks and trailers
// This tests the STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER content-sha256 header value
// which is used by AWS SDK v2 when checksum validation is enabled
func TestSignedStreamingUploadWithTrailer(t *testing.T) {
iam := setupIam()
req, expectedData := createTrailerStreamingRequest(t, true)
// Test the chunked reader
reader, errCode := iam.newChunkedReader(req)
assert.Equal(t, s3err.ErrNone, errCode)
assert.NotNil(t, reader)
// Read and verify the payload
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, expectedData, string(data))
}
// TestSignedStreamingUploadWithTrailerInvalidSignature tests behavior with invalid trailer signatures.
// This is a negative test case for trailer signature validation. It currently verifies that an invalid
// signature doesn't break content reading, and is prepared for when validation is implemented.
func TestSignedStreamingUploadWithTrailerInvalidSignature(t *testing.T) {
iam := setupIam()
req, expectedData := createTrailerStreamingRequest(t, false)
// Test the chunked reader - it should be created successfully
reader, errCode := iam.newChunkedReader(req)
assert.Equal(t, s3err.ErrNone, errCode)
assert.NotNil(t, reader)
// Read the payload - currently trailer signature validation may not be implemented,
// but this test documents the expected behavior and will catch regressions
// if trailer signature validation is added in the future
data, err := io.ReadAll(reader)
// Note: If trailer signature validation is implemented, this should fail with an error
// For now, we just verify the content is correctly extracted
if err != nil {
assert.Contains(t, err.Error(), "signature", "Error should indicate signature mismatch")
} else {
// If no error, content should still be correct (trailer sig validation not yet implemented)
assert.Equal(t, expectedData, string(data))
}
}
// TestSignedStreamingUploadInvalidSignature tests that invalid chunk signatures are rejected
// This is a negative test case to ensure signature validation is actually working
func TestSignedStreamingUploadInvalidSignature(t *testing.T) {
iam := setupIam()
// Create a simple streaming upload with 1 chunk
chunk1Data := strings.Repeat("a", 1024)
// Use current time for signatures
now := time.Now().UTC()
amzDate := now.Format(iso8601Format)
dateStamp := now.Format(yyyymmdd)
// Calculate seed signature
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
// Build canonical request for seed signature
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
canonicalHeaders := "content-encoding:aws-chunked\n" +
"host:s3.amazonaws.com\n" +
"x-amz-content-sha256:" + hashedPayload + "\n" +
"x-amz-date:" + amzDate + "\n" +
"x-amz-decoded-content-length:1024\n"
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length"
canonicalRequest := "PUT\n" +
"/test-bucket/test-object\n" +
"\n" +
canonicalHeaders + "\n" +
signedHeaders + "\n" +
hashedPayload
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
seedSignature := getSignature(signingKey, stringToSign)
// Calculate chunk signature (correct)
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
// Calculate final signature (correct)
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
chunk1Signature + "\n" + emptySHA256 + "\n" + emptySHA256
finalSignature := getSignature(signingKey, finalStringToSign)
// Build the chunked payload with INTENTIONALLY WRONG chunk signature
// We'll use a modified signature to simulate a tampered request
wrongChunkSignatureBytes := []byte(chunk1Signature)
if len(wrongChunkSignatureBytes) > 0 {
// Flip the first hex character to guarantee a different signature
if wrongChunkSignatureBytes[0] == '0' {
wrongChunkSignatureBytes[0] = '1'
} else {
wrongChunkSignatureBytes[0] = '0'
}
}
wrongChunkSignature := string(wrongChunkSignatureBytes)
payload := fmt.Sprintf("400;chunk-signature=%s\r\n%s\r\n", wrongChunkSignature, chunk1Data) +
fmt.Sprintf("0;chunk-signature=%s\r\n\r\n", finalSignature)
// Create the request
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
bytes.NewReader([]byte(payload)))
assert.NoError(t, err)
req.Header.Set("Host", "s3.amazonaws.com")
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", hashedPayload)
req.Header.Set("Content-Encoding", "aws-chunked")
req.Header.Set("x-amz-decoded-content-length", "1024")
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
defaultAccessKeyId, scope, signedHeaders, seedSignature)
req.Header.Set("Authorization", authHeader)
// Test the chunked reader - it should be created successfully
reader, errCode := iam.newChunkedReader(req)
assert.Equal(t, s3err.ErrNone, errCode)
assert.NotNil(t, reader)
// Try to read the payload - this should fail with signature validation error
_, err = io.ReadAll(reader)
assert.Error(t, err, "Expected error when reading chunk with invalid signature")
assert.Contains(t, err.Error(), "chunk signature does not match", "Error should indicate chunk signature mismatch")
}

View File

@@ -0,0 +1,277 @@
package s3api
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func TestCopySourceWithExclamationMark(t *testing.T) {
// Reproduce https://github.com/seaweedfs/seaweedfs/issues/8544
testCases := []struct {
name string
rawCopySource string
expectedBucket string
expectedObject string
expectedVersion string
dstBucket string
dstObject string
shouldBeEqual bool
}{
{
name: "encoded exclamation mark - different dest",
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
expectedBucket: "my-bucket",
expectedObject: "path/to/Another test!.odt",
dstBucket: "my-bucket",
dstObject: "path/to/Hello.odt",
shouldBeEqual: false,
},
{
name: "unencoded exclamation mark - different dest",
rawCopySource: "my-bucket/path/to/Another%20test!.odt",
expectedBucket: "my-bucket",
expectedObject: "path/to/Another test!.odt",
dstBucket: "my-bucket",
dstObject: "path/to/Hello.odt",
shouldBeEqual: false,
},
{
name: "encoded exclamation mark - same dest",
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
expectedBucket: "my-bucket",
expectedObject: "path/to/Another test!.odt",
dstBucket: "my-bucket",
dstObject: "path/to/Another test!.odt",
shouldBeEqual: true,
},
{
name: "encoded path with versionId",
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt?versionId=abc123",
expectedBucket: "my-bucket",
expectedObject: "path/to/Another test!.odt",
expectedVersion: "abc123",
dstBucket: "my-bucket",
dstObject: "path/to/Hello.odt",
shouldBeEqual: false,
},
{
name: "unencoded path with versionId",
rawCopySource: "my-bucket/path/to/Another%20test!.odt?versionId=v2",
expectedBucket: "my-bucket",
expectedObject: "path/to/Another test!.odt",
expectedVersion: "v2",
dstBucket: "my-bucket",
dstObject: "path/to/Another test!.odt",
shouldBeEqual: true,
},
{
name: "plus sign in key with versionId",
rawCopySource: "my-bucket/path/to/file+name.odt?versionId=xyz",
expectedBucket: "my-bucket",
expectedObject: "path/to/file+name.odt",
expectedVersion: "xyz",
dstBucket: "my-bucket",
dstObject: "path/to/file+name.odt",
shouldBeEqual: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cpSrcPath, err := url.PathUnescape(tc.rawCopySource)
if err != nil {
cpSrcPath = tc.rawCopySource
}
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(tc.rawCopySource, cpSrcPath)
if srcBucket != tc.expectedBucket {
t.Errorf("expected srcBucket=%q, got %q", tc.expectedBucket, srcBucket)
}
if srcObject != tc.expectedObject {
t.Errorf("expected srcObject=%q, got %q", tc.expectedObject, srcObject)
}
if srcVersionId != tc.expectedVersion {
t.Errorf("expected versionId=%q, got %q", tc.expectedVersion, srcVersionId)
}
isEqual := srcBucket == tc.dstBucket && srcObject == tc.dstObject
if isEqual != tc.shouldBeEqual {
t.Errorf("expected comparison result %v, got %v (srcObject=%q, dstObject=%q)",
tc.shouldBeEqual, isEqual, srcObject, tc.dstObject)
}
})
}
}
// TestCopySourceDecodingPlusSign verifies that + in the copy source header
// is treated as a literal plus sign (not a space), matching url.PathUnescape
// behavior. Using url.QueryUnescape would incorrectly convert + to space.
func TestCopySourceDecodingPlusSign(t *testing.T) {
// Key: "path/to/file+name.odt"
// With url.QueryUnescape, literal "+" → " " (WRONG for paths)
// With url.PathUnescape, literal "+" → "+" (CORRECT)
rawCopySource := "my-bucket/path/to/file+name.odt"
// url.QueryUnescape would give "file name.odt" — WRONG
queryDecoded, _ := url.QueryUnescape(rawCopySource)
if queryDecoded == rawCopySource {
t.Fatal("QueryUnescape should have changed + to space")
}
// url.PathUnescape preserves literal "+" — CORRECT
pathDecoded, _ := url.PathUnescape(rawCopySource)
if pathDecoded != rawCopySource {
t.Fatalf("PathUnescape should have preserved +, got %q", pathDecoded)
}
_, srcObject, _ := pathToBucketObjectAndVersion(rawCopySource, pathDecoded)
if srcObject != "path/to/file+name.odt" {
t.Errorf("expected srcObject=%q, got %q", "path/to/file+name.odt", srcObject)
}
}
// TestCopySourceRejectsTraversal verifies `.`/`..` segments in a copy source
// are rejected so the source stays within the parsed bucket.
func TestCopySourceRejectsTraversal(t *testing.T) {
tests := []struct {
name string
rawCopySource string
wantErr bool
}{
{"clean source passes", "bucket-a/dir/key", false},
{"leading slash source passes", "/bucket-a/dir/key", false},
{"source with versionId passes", "bucket-a/dir/key?versionId=v1", false},
{"dotdot escapes bucket", "bucket-a/../bucket-secret/flag", true},
{"leading slash dotdot escapes bucket", "/bucket-a/../bucket-secret/flag", true},
{"percent-encoded dotdot escapes bucket", "bucket-a/%2e%2e/bucket-secret/flag", true},
{"encoded slash dotdot escapes bucket", "bucket-a/..%2fbucket-secret/flag", true},
{"backslash dotdot escapes bucket", "bucket-a/..\\bucket-secret/flag", true},
{"nested dotdot escapes bucket", "bucket-a/dir/../../bucket-secret/flag", true},
{"bare dot segment rejected", "bucket-a/./key", true},
{"dotdot bucket rejected", "../bucket-secret/flag", true},
{"dotdot with versionId escapes bucket", "bucket-a/../bucket-secret/flag?versionId=v1", true},
{"versionId encoded slash rejected", "bucket-a/dir/key?versionId=v1%2F..%2Fsecret", true},
{"versionId backslash rejected", "bucket-a/dir/key?versionId=v1%5C..%5Csecret", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cpSrcPath, err := url.PathUnescape(tc.rawCopySource)
if err != nil {
cpSrcPath = tc.rawCopySource
}
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(tc.rawCopySource, cpSrcPath)
gotErr := validateCopySource(cpSrcPath, srcBucket, srcObject, srcVersionId) != nil
if gotErr != tc.wantErr {
t.Errorf("ValidateCopySource(%q) error = %v, want %v (parsed bucket=%q object=%q)",
tc.rawCopySource, gotErr, tc.wantErr, srcBucket, srcObject)
}
})
}
}
// TestCopySourceRoutingWithSpecialChars tests that mux variable extraction
// correctly handles special characters like ! (%21) in both the URL path
// and the X-Amz-Copy-Source header.
func TestCopySourceRoutingWithSpecialChars(t *testing.T) {
testCases := []struct {
name string
dstURL string // URL for the PUT request (destination)
copySource string // X-Amz-Copy-Source header value
expectSameKey bool // whether srcObject should equal dstObject
}{
{
name: "different keys, source has encoded !",
dstURL: "/my-bucket/path/to/Hello.odt",
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
expectSameKey: false,
},
{
name: "same key with !, source encoded, dest unencoded",
dstURL: "/my-bucket/path/to/Another%20test%21.odt",
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
expectSameKey: true,
},
{
name: "same key with !, both percent-encoded differently",
dstURL: "/my-bucket/path/to/Another%20test!.odt",
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
expectSameKey: true,
},
{
name: "key with + sign, source has literal +",
dstURL: "/my-bucket/path/to/file+name.odt",
copySource: "my-bucket/path/to/file+name.odt",
expectSameKey: true,
},
{
name: "key with + sign, source has %2B",
dstURL: "/my-bucket/path/to/file+name.odt",
copySource: "my-bucket/path/to/file%2Bname.odt",
expectSameKey: true,
},
{
name: "lowercase %2f in copy source",
dstURL: "/my-bucket/path/to/Hello.odt",
copySource: "my-bucket/path%2fto%2fAnother%20test.odt",
expectSameKey: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var capturedDstBucket, capturedDstObject string
var capturedSrcBucket, capturedSrcObject string
router := mux.NewRouter().SkipClean(true)
bucket := router.PathPrefix("/{bucket}").Subrouter()
bucket.Methods(http.MethodPut).Path("/{object:(?s).+}").
HeadersRegexp("X-Amz-Copy-Source", `(?i).*?(\/|%2F).*?`).
HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedDstBucket, capturedDstObject = s3_constants.GetBucketAndObject(r)
rawCopySource := r.Header.Get("X-Amz-Copy-Source")
cpSrcPath, err := url.PathUnescape(rawCopySource)
if err != nil {
cpSrcPath = rawCopySource
}
capturedSrcBucket, capturedSrcObject, _ = pathToBucketObjectAndVersion(rawCopySource, cpSrcPath)
if capturedSrcBucket == capturedDstBucket && capturedSrcObject == capturedDstObject {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "ErrInvalidCopyDest")
} else {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "OK")
}
})
req, _ := http.NewRequest("PUT", tc.dstURL, nil)
req.Header.Set("X-Amz-Copy-Source", tc.copySource)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if tc.expectSameKey {
if rr.Code != http.StatusBadRequest {
t.Errorf("expected same key detection (400), got %d; src=%q dst=%q",
rr.Code, capturedSrcObject, capturedDstObject)
}
} else {
if rr.Code != http.StatusOK {
t.Errorf("expected different keys (200), got %d; src=%q dst=%q",
rr.Code, capturedSrcObject, capturedDstObject)
}
}
})
}
}

385
weed/s3api/cors/cors.go Normal file
View File

@@ -0,0 +1,385 @@
package cors
import (
"fmt"
"net/http"
"strconv"
"strings"
)
// CORSRule represents a single CORS rule
type CORSRule struct {
AllowedHeaders []string `xml:"AllowedHeader,omitempty" json:"AllowedHeaders,omitempty"`
AllowedMethods []string `xml:"AllowedMethod" json:"AllowedMethods"`
AllowedOrigins []string `xml:"AllowedOrigin" json:"AllowedOrigins"`
ExposeHeaders []string `xml:"ExposeHeader,omitempty" json:"ExposeHeaders,omitempty"`
MaxAgeSeconds *int `xml:"MaxAgeSeconds,omitempty" json:"MaxAgeSeconds,omitempty"`
ID string `xml:"ID,omitempty" json:"ID,omitempty"`
}
// CORSConfiguration represents the CORS configuration for a bucket
type CORSConfiguration struct {
CORSRules []CORSRule `xml:"CORSRule" json:"CORSRules"`
}
// CORSRequest represents a CORS request
type CORSRequest struct {
Origin string
Method string
RequestHeaders []string
IsPreflightRequest bool
AccessControlRequestMethod string
AccessControlRequestHeaders []string
}
// CORSResponse represents the response for a CORS request
type CORSResponse struct {
AllowOrigin string
AllowMethods string
AllowHeaders string
ExposeHeaders string
MaxAge string
AllowCredentials bool
}
// ValidateConfiguration validates a CORS configuration
func ValidateConfiguration(config *CORSConfiguration) error {
if config == nil {
return fmt.Errorf("CORS configuration cannot be nil")
}
if len(config.CORSRules) == 0 {
return fmt.Errorf("CORS configuration must have at least one rule")
}
if len(config.CORSRules) > 100 {
return fmt.Errorf("CORS configuration cannot have more than 100 rules")
}
for i, rule := range config.CORSRules {
if err := validateRule(&rule); err != nil {
return fmt.Errorf("invalid CORS rule at index %d: %v", i, err)
}
}
return nil
}
// ParseRequest parses an HTTP request to extract CORS information
func ParseRequest(r *http.Request) *CORSRequest {
corsReq := &CORSRequest{
Origin: r.Header.Get("Origin"),
Method: r.Method,
}
// Check if this is a preflight request
if r.Method == "OPTIONS" {
corsReq.IsPreflightRequest = true
corsReq.AccessControlRequestMethod = r.Header.Get("Access-Control-Request-Method")
if headers := r.Header.Get("Access-Control-Request-Headers"); headers != "" {
corsReq.AccessControlRequestHeaders = strings.Split(headers, ",")
for i := range corsReq.AccessControlRequestHeaders {
corsReq.AccessControlRequestHeaders[i] = strings.TrimSpace(corsReq.AccessControlRequestHeaders[i])
}
}
}
return corsReq
}
// validateRule validates a single CORS rule
func validateRule(rule *CORSRule) error {
if len(rule.AllowedMethods) == 0 {
return fmt.Errorf("AllowedMethods cannot be empty")
}
if len(rule.AllowedOrigins) == 0 {
return fmt.Errorf("AllowedOrigins cannot be empty")
}
// Validate allowed methods
validMethods := map[string]bool{
"GET": true,
"PUT": true,
"POST": true,
"DELETE": true,
"HEAD": true,
}
for _, method := range rule.AllowedMethods {
if !validMethods[method] {
return fmt.Errorf("invalid HTTP method: %s", method)
}
}
// Validate origins
for _, origin := range rule.AllowedOrigins {
if origin == "*" {
continue
}
if err := validateOrigin(origin); err != nil {
return fmt.Errorf("invalid origin %s: %v", origin, err)
}
}
// Validate MaxAgeSeconds
if rule.MaxAgeSeconds != nil && *rule.MaxAgeSeconds < 0 {
return fmt.Errorf("MaxAgeSeconds cannot be negative")
}
return nil
}
// validateOrigin validates an origin string
func validateOrigin(origin string) error {
if origin == "" {
return fmt.Errorf("origin cannot be empty")
}
// Special case: "*" is always valid
if origin == "*" {
return nil
}
// Count wildcards
wildcardCount := strings.Count(origin, "*")
if wildcardCount > 1 {
return fmt.Errorf("origin can contain at most one wildcard")
}
// If there's a wildcard, it should be in a valid position
if wildcardCount == 1 {
// Must be in the format: http://*.example.com or https://*.example.com
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
return fmt.Errorf("origin with wildcard must start with http:// or https://")
}
}
return nil
}
// EvaluateRequest evaluates a CORS request against a CORS configuration
func EvaluateRequest(config *CORSConfiguration, corsReq *CORSRequest) (*CORSResponse, error) {
if config == nil || corsReq == nil {
return nil, fmt.Errorf("config and corsReq cannot be nil")
}
if corsReq.Origin == "" {
return nil, fmt.Errorf("origin header is required for CORS requests")
}
// Find the first rule that matches the origin
for _, rule := range config.CORSRules {
if matchesOrigin(rule.AllowedOrigins, corsReq.Origin) {
// For preflight requests, we need more detailed validation
if corsReq.IsPreflightRequest {
return buildPreflightResponse(&rule, corsReq), nil
} else {
// For actual requests, check method
if containsString(rule.AllowedMethods, corsReq.Method) {
return buildResponse(&rule, corsReq), nil
}
}
}
}
return nil, fmt.Errorf("no matching CORS rule found")
}
// buildPreflightResponse builds a CORS response for preflight requests
func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
response := &CORSResponse{
AllowOrigin: corsReq.Origin,
}
// Check if the requested method is allowed
methodAllowed := corsReq.AccessControlRequestMethod == "" || containsString(rule.AllowedMethods, corsReq.AccessControlRequestMethod)
// Check requested headers
var allowedRequestHeaders []string
allHeadersAllowed := true
if len(corsReq.AccessControlRequestHeaders) > 0 {
// Check if wildcard is allowed
hasWildcard := false
for _, header := range rule.AllowedHeaders {
if header == "*" {
hasWildcard = true
break
}
}
if hasWildcard {
// All requested headers are allowed with wildcard
allowedRequestHeaders = corsReq.AccessControlRequestHeaders
} else {
// Check each requested header individually
for _, requestedHeader := range corsReq.AccessControlRequestHeaders {
if matchesHeader(rule.AllowedHeaders, requestedHeader) {
allowedRequestHeaders = append(allowedRequestHeaders, requestedHeader)
} else {
allHeadersAllowed = false
}
}
}
}
// Only set method and header info if both method and ALL headers are allowed
if methodAllowed && allHeadersAllowed {
response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
if len(allowedRequestHeaders) > 0 {
response.AllowHeaders = strings.Join(allowedRequestHeaders, ", ")
}
// Set exposed headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
}
// Set max age
if rule.MaxAgeSeconds != nil {
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
}
}
return response
}
// buildResponse builds a CORS response from a matching rule
func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
response := &CORSResponse{
AllowOrigin: corsReq.Origin,
}
// Set allowed methods
response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
// Set allowed headers
if len(rule.AllowedHeaders) > 0 {
response.AllowHeaders = strings.Join(rule.AllowedHeaders, ", ")
}
// Set expose headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
}
// Set max age
if rule.MaxAgeSeconds != nil {
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
}
return response
}
// Helper functions
// matchesOrigin checks if the request origin matches any allowed origin
func matchesOrigin(allowedOrigins []string, origin string) bool {
for _, allowedOrigin := range allowedOrigins {
if allowedOrigin == "*" {
return true
}
if allowedOrigin == origin {
return true
}
// Handle wildcard patterns like https://*.example.com
if strings.Contains(allowedOrigin, "*") {
if matchWildcard(allowedOrigin, origin) {
return true
}
}
}
return false
}
// matchWildcard performs wildcard matching for origins
func matchWildcard(pattern, text string) bool {
// Simple wildcard matching - only supports single * at the beginning
if strings.HasPrefix(pattern, "http://*") {
suffix := pattern[8:] // Remove "http://*"
return strings.HasPrefix(text, "http://") && strings.HasSuffix(text, suffix)
}
if strings.HasPrefix(pattern, "https://*") {
suffix := pattern[9:] // Remove "https://*"
return strings.HasPrefix(text, "https://") && strings.HasSuffix(text, suffix)
}
return false
}
// matchesHeader checks if a header is allowed
func matchesHeader(allowedHeaders []string, header string) bool {
// If no headers are specified, all headers are allowed
if len(allowedHeaders) == 0 {
return true
}
// Header matching is case-insensitive
header = strings.ToLower(header)
for _, allowedHeader := range allowedHeaders {
allowedHeaderLower := strings.ToLower(allowedHeader)
// Wildcard match
if allowedHeaderLower == "*" {
return true
}
// Exact match
if allowedHeaderLower == header {
return true
}
// Prefix wildcard match (e.g., "x-amz-*" matches "x-amz-date")
if strings.HasSuffix(allowedHeaderLower, "*") {
prefix := strings.TrimSuffix(allowedHeaderLower, "*")
if strings.HasPrefix(header, prefix) {
return true
}
}
}
return false
}
// containsString checks if a slice contains a specific string
func containsString(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// ApplyHeaders applies CORS headers to an HTTP response
func ApplyHeaders(w http.ResponseWriter, corsResp *CORSResponse) {
if corsResp == nil {
return
}
if corsResp.AllowOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", corsResp.AllowOrigin)
}
if corsResp.AllowMethods != "" {
w.Header().Set("Access-Control-Allow-Methods", corsResp.AllowMethods)
}
if corsResp.AllowHeaders != "" {
w.Header().Set("Access-Control-Allow-Headers", corsResp.AllowHeaders)
}
if corsResp.ExposeHeaders != "" {
w.Header().Set("Access-Control-Expose-Headers", corsResp.ExposeHeaders)
}
if corsResp.MaxAge != "" {
w.Header().Set("Access-Control-Max-Age", corsResp.MaxAge)
}
if corsResp.AllowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
}

View File

@@ -0,0 +1,569 @@
package cors
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func TestValidateConfiguration(t *testing.T) {
tests := []struct {
name string
config *CORSConfiguration
wantErr bool
}{
{
name: "nil config",
config: nil,
wantErr: true,
},
{
name: "empty rules",
config: &CORSConfiguration{
CORSRules: []CORSRule{},
},
wantErr: true,
},
{
name: "valid single rule",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedMethods: []string{"GET", "POST"},
AllowedOrigins: []string{"*"},
},
},
},
wantErr: false,
},
{
name: "too many rules",
config: &CORSConfiguration{
CORSRules: make([]CORSRule, 101),
},
wantErr: true,
},
{
name: "invalid method",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedMethods: []string{"INVALID"},
AllowedOrigins: []string{"*"},
},
},
},
wantErr: true,
},
{
name: "empty origins",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedMethods: []string{"GET"},
AllowedOrigins: []string{},
},
},
},
wantErr: true,
},
{
name: "invalid origin with multiple wildcards",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedMethods: []string{"GET"},
AllowedOrigins: []string{"http://*.*.example.com"},
},
},
},
wantErr: true,
},
{
name: "negative MaxAgeSeconds",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedMethods: []string{"GET"},
AllowedOrigins: []string{"*"},
MaxAgeSeconds: intPtr(-1),
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateConfiguration(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateConfiguration() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidateOrigin(t *testing.T) {
tests := []struct {
name string
origin string
wantErr bool
}{
{
name: "empty origin",
origin: "",
wantErr: true,
},
{
name: "valid origin",
origin: "http://example.com",
wantErr: false,
},
{
name: "wildcard origin",
origin: "*",
wantErr: false,
},
{
name: "valid wildcard origin",
origin: "http://*.example.com",
wantErr: false,
},
{
name: "https wildcard origin",
origin: "https://*.example.com",
wantErr: false,
},
{
name: "invalid wildcard origin",
origin: "*.example.com",
wantErr: true,
},
{
name: "multiple wildcards",
origin: "http://*.*.example.com",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateOrigin(tt.origin)
if (err != nil) != tt.wantErr {
t.Errorf("validateOrigin() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestParseRequest(t *testing.T) {
tests := []struct {
name string
req *http.Request
want *CORSRequest
}{
{
name: "simple GET request",
req: &http.Request{
Method: "GET",
Header: http.Header{
"Origin": []string{"http://example.com"},
},
},
want: &CORSRequest{
Origin: "http://example.com",
Method: "GET",
IsPreflightRequest: false,
},
},
{
name: "OPTIONS preflight request",
req: &http.Request{
Method: "OPTIONS",
Header: http.Header{
"Origin": []string{"http://example.com"},
"Access-Control-Request-Method": []string{"PUT"},
"Access-Control-Request-Headers": []string{"Content-Type, Authorization"},
},
},
want: &CORSRequest{
Origin: "http://example.com",
Method: "OPTIONS",
IsPreflightRequest: true,
AccessControlRequestMethod: "PUT",
AccessControlRequestHeaders: []string{"Content-Type", "Authorization"},
},
},
{
name: "request without origin",
req: &http.Request{
Method: "GET",
Header: http.Header{},
},
want: &CORSRequest{
Origin: "",
Method: "GET",
IsPreflightRequest: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseRequest(tt.req)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestMatchesOrigin(t *testing.T) {
tests := []struct {
name string
allowedOrigins []string
origin string
want bool
}{
{
name: "wildcard match",
allowedOrigins: []string{"*"},
origin: "http://example.com",
want: true,
},
{
name: "exact match",
allowedOrigins: []string{"http://example.com"},
origin: "http://example.com",
want: true,
},
{
name: "no match",
allowedOrigins: []string{"http://example.com"},
origin: "http://other.com",
want: false,
},
{
name: "wildcard subdomain match",
allowedOrigins: []string{"http://*.example.com"},
origin: "http://api.example.com",
want: true,
},
{
name: "wildcard subdomain no match",
allowedOrigins: []string{"http://*.example.com"},
origin: "http://example.com",
want: false,
},
{
name: "multiple origins with match",
allowedOrigins: []string{"http://example.com", "http://other.com"},
origin: "http://other.com",
want: true,
},
// HTTPS test cases
{
name: "https exact match",
allowedOrigins: []string{"https://example.com"},
origin: "https://example.com",
want: true,
},
{
name: "https no match",
allowedOrigins: []string{"https://example.com"},
origin: "https://other.com",
want: false,
},
{
name: "https wildcard subdomain match",
allowedOrigins: []string{"https://*.example.com"},
origin: "https://api.example.com",
want: true,
},
{
name: "https wildcard subdomain no match - base domain",
allowedOrigins: []string{"https://*.example.com"},
origin: "https://example.com",
want: false,
},
{
name: "https wildcard subdomain no match - different domain",
allowedOrigins: []string{"https://*.example.com"},
origin: "https://api.other.com",
want: false,
},
{
name: "protocol mismatch - http pattern https origin",
allowedOrigins: []string{"http://*.example.com"},
origin: "https://api.example.com",
want: false,
},
{
name: "protocol mismatch - https pattern http origin",
allowedOrigins: []string{"https://*.example.com"},
origin: "http://api.example.com",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchesOrigin(tt.allowedOrigins, tt.origin)
if got != tt.want {
t.Errorf("matchesOrigin() = %v, want %v", got, tt.want)
}
})
}
}
func TestMatchesHeader(t *testing.T) {
tests := []struct {
name string
allowedHeaders []string
header string
want bool
}{
{
name: "empty allowed headers",
allowedHeaders: []string{},
header: "Content-Type",
want: true,
},
{
name: "wildcard match",
allowedHeaders: []string{"*"},
header: "Content-Type",
want: true,
},
{
name: "exact match",
allowedHeaders: []string{"Content-Type"},
header: "Content-Type",
want: true,
},
{
name: "case insensitive match",
allowedHeaders: []string{"content-type"},
header: "Content-Type",
want: true,
},
{
name: "no match",
allowedHeaders: []string{"Authorization"},
header: "Content-Type",
want: false,
},
{
name: "wildcard prefix match",
allowedHeaders: []string{"x-amz-*"},
header: "x-amz-date",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchesHeader(tt.allowedHeaders, tt.header)
if got != tt.want {
t.Errorf("matchesHeader() = %v, want %v", got, tt.want)
}
})
}
}
func TestEvaluateRequest(t *testing.T) {
config := &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedMethods: []string{"GET", "POST"},
AllowedOrigins: []string{"http://example.com"},
AllowedHeaders: []string{"Content-Type"},
ExposeHeaders: []string{"ETag"},
MaxAgeSeconds: intPtr(3600),
},
{
AllowedMethods: []string{"PUT"},
AllowedOrigins: []string{"*"},
},
},
}
tests := []struct {
name string
config *CORSConfiguration
corsReq *CORSRequest
want *CORSResponse
wantErr bool
}{
{
name: "matching first rule",
config: config,
corsReq: &CORSRequest{
Origin: "http://example.com",
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
},
wantErr: false,
},
{
name: "matching second rule",
config: config,
corsReq: &CORSRequest{
Origin: "http://other.com",
Method: "PUT",
},
want: &CORSResponse{
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
},
wantErr: false,
},
{
name: "no matching rule",
config: config,
corsReq: &CORSRequest{
Origin: "http://forbidden.com",
Method: "GET",
},
want: nil,
wantErr: true,
},
{
name: "preflight request",
config: config,
corsReq: &CORSRequest{
Origin: "http://example.com",
Method: "OPTIONS",
IsPreflightRequest: true,
AccessControlRequestMethod: "POST",
AccessControlRequestHeaders: []string{"Content-Type"},
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
},
wantErr: false,
},
{
name: "preflight request with forbidden header",
config: config,
corsReq: &CORSRequest{
Origin: "http://example.com",
Method: "OPTIONS",
IsPreflightRequest: true,
AccessControlRequestMethod: "POST",
AccessControlRequestHeaders: []string{"Authorization"},
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
// No AllowMethods or AllowHeaders because the requested header is forbidden
},
wantErr: false,
},
{
name: "request without origin",
config: config,
corsReq: &CORSRequest{
Origin: "",
Method: "GET",
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := EvaluateRequest(tt.config, tt.corsReq)
if (err != nil) != tt.wantErr {
t.Errorf("EvaluateRequest() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("EvaluateRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestApplyHeaders(t *testing.T) {
tests := []struct {
name string
corsResp *CORSResponse
want map[string]string
}{
{
name: "nil response",
corsResp: nil,
want: map[string]string{},
},
{
name: "complete response",
corsResp: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
},
want: map[string]string{
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
},
},
{
name: "with credentials",
corsResp: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET",
AllowCredentials: true,
},
want: map[string]string{
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Credentials": "true",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a proper response writer using httptest
w := httptest.NewRecorder()
ApplyHeaders(w, tt.corsResp)
// Extract headers from the response
headers := make(map[string]string)
for key, values := range w.Header() {
if len(values) > 0 {
headers[key] = values[0]
}
}
if !reflect.DeepEqual(headers, tt.want) {
t.Errorf("ApplyHeaders() headers = %v, want %v", headers, tt.want)
}
})
}
}
// Helper functions and types for testing
func intPtr(i int) *int {
return &i
}

View File

@@ -0,0 +1,142 @@
package cors
import (
"net/http"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// BucketChecker interface for checking bucket existence
type BucketChecker interface {
CheckBucket(r *http.Request, bucket string) s3err.ErrorCode
}
// CORSConfigGetter interface for getting CORS configuration
type CORSConfigGetter interface {
GetCORSConfiguration(bucket string) (*CORSConfiguration, s3err.ErrorCode)
}
// Middleware handles CORS evaluation for all S3 API requests
type Middleware struct {
bucketChecker BucketChecker
corsConfigGetter CORSConfigGetter
fallbackConfig *CORSConfiguration // Global CORS configuration as fallback
}
// NewMiddleware creates a new CORS middleware instance with optional global fallback config
func NewMiddleware(bucketChecker BucketChecker, corsConfigGetter CORSConfigGetter, fallbackConfig *CORSConfiguration) *Middleware {
return &Middleware{
bucketChecker: bucketChecker,
corsConfigGetter: corsConfigGetter,
fallbackConfig: fallbackConfig,
}
}
// getCORSConfig retrieves the applicable CORS configuration, trying bucket-specific first, then fallback.
// Returns the configuration and a boolean indicating if any configuration was found.
// Only falls back to global config when there's explicitly no bucket-level config.
// For other errors (e.g., access denied), returns false to let the handler deny the request.
func (m *Middleware) getCORSConfig(bucket string) (*CORSConfiguration, bool) {
config, errCode := m.corsConfigGetter.GetCORSConfiguration(bucket)
switch errCode {
case s3err.ErrNone:
if config != nil {
// Found a bucket-specific config, use it.
return config, true
}
// No bucket config, proceed to fallback.
case s3err.ErrNoSuchCORSConfiguration:
// No bucket config, proceed to fallback.
case s3err.ErrNoSuchBucket:
// Bucket doesn't exist, proceed to fallback.
// This ensures we don't leak existence information by returning 403 vs 200.
default:
// Any other error means we should not proceed.
return nil, false
}
// No bucket-level config found, try global fallback
if m.fallbackConfig != nil {
return m.fallbackConfig, true
}
return nil, false
}
// Handler returns the CORS middleware handler
func (m *Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.processCORS(w, r, func() {
next.ServeHTTP(w, r)
})
})
}
// HandleOptionsRequest handles OPTIONS requests for CORS preflight
func (m *Middleware) HandleOptionsRequest(w http.ResponseWriter, r *http.Request) {
m.processCORS(w, r, func() {
w.WriteHeader(http.StatusOK)
})
}
// processCORS handles the common CORS logic for both regular and preflight requests.
// It takes a next callback which is executed if the request flow proceeds (i.e., not short-circuited by CORS errors or preflight responses).
func (m *Middleware) processCORS(w http.ResponseWriter, r *http.Request, next func()) {
corsReq := ParseRequest(r)
bucket, _ := s3_constants.GetBucketAndObject(r)
// 1. Basic Validation
if bucket == "" {
next()
return
}
// 2. Load Configuration
config, hasConfig := m.getCORSConfig(bucket)
// 3. Apply Vary Header (Always applied if config exists)
if hasConfig {
w.Header().Add("Vary", "Origin")
}
// 4. Handle Non-CORS Requests
if corsReq.Origin == "" {
next()
return
}
// 5. Handle Missing Configuration
if !hasConfig {
if corsReq.IsPreflightRequest {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
next()
return
}
// 6. Evaluate CORS Request
corsResp, err := EvaluateRequest(config, corsReq)
if err != nil {
glog.V(3).Infof("CORS evaluation failed for bucket %s: %v", bucket, err)
if corsReq.IsPreflightRequest {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
next()
return
}
// 7. Success Case
ApplyHeaders(w, corsResp)
if corsReq.IsPreflightRequest {
w.WriteHeader(http.StatusOK)
return
}
next()
}

View File

@@ -0,0 +1,240 @@
package cors
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// TestMiddlewareNonExistentBucket tests that CORS headers are applied even for non-existent buckets
// This prevents information disclosure about bucket existence and ensures consistent CORS behavior
func TestMiddlewareNonExistentBucket(t *testing.T) {
tests := []struct {
name string
fallbackConfig *CORSConfiguration
requestOrigin string
requestMethod string
isOptions bool
expectedStatus int
expectedOriginHeader string
description string
}{
{
name: "Preflight request to non-existent bucket with global CORS config",
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://example.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://example.com",
description: "Preflight to non-existent bucket should succeed with CORS headers",
},
{
name: "Actual request to non-existent bucket with global CORS config",
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://example.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusNotFound, // Handler returns NoSuchBucket
expectedOriginHeader: "https://example.com",
description: "GET to non-existent bucket should have CORS headers even with NoSuchBucket error",
},
{
name: "Preflight to non-existent bucket with specific origin",
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://allowed.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://allowed.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://allowed.com",
description: "Preflight to non-existent bucket with matching origin should succeed",
},
{
name: "Preflight to non-existent bucket with non-matching origin",
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://allowed.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://notallowed.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusForbidden,
expectedOriginHeader: "",
description: "Preflight to non-existent bucket with non-matching origin should fail",
},
{
name: "Preflight to non-existent bucket without CORS config",
fallbackConfig: nil,
requestOrigin: "https://example.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusForbidden,
expectedOriginHeader: "",
description: "Preflight to non-existent bucket without CORS config should fail",
},
{
name: "Bucket listing request to non-existent bucket with CORS",
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://mydomain.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://mydomain.com",
description: "Bucket listing preflight to non-existent bucket should have CORS headers (issue #8065)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mocks - bucket does NOT exist
bucketChecker := &mockBucketChecker{bucketExists: false}
configGetter := &mockCORSConfigGetter{
config: nil, // No bucket-specific config
errCode: s3err.ErrNone,
}
// Create middleware with fallback config
middleware := NewMiddleware(bucketChecker, configGetter, tt.fallbackConfig)
// Create request with mux variables
req := httptest.NewRequest(tt.requestMethod, "/nonexistent-bucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "nonexistent-bucket",
"object": "testobject",
})
if tt.requestOrigin != "" {
req.Header.Set("Origin", tt.requestOrigin)
}
if tt.isOptions {
req.Header.Set("Access-Control-Request-Method", "GET")
}
// Create response recorder
w := httptest.NewRecorder()
// Create a handler that returns 404 for non-existent buckets
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate NoSuchBucket error
w.WriteHeader(http.StatusNotFound)
})
// Execute middleware
if tt.isOptions {
middleware.HandleOptionsRequest(w, req)
} else {
middleware.Handler(nextHandler).ServeHTTP(w, req)
}
// Check status code
if w.Code != tt.expectedStatus {
t.Errorf("%s: expected status %d, got %d", tt.description, tt.expectedStatus, w.Code)
}
// Check CORS header
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
if actualOrigin != tt.expectedOriginHeader {
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
tt.description, tt.expectedOriginHeader, actualOrigin)
}
})
}
}
// TestMiddlewareConsistentBehavior tests that CORS headers are consistent regardless of bucket existence
// This is a security test to ensure bucket existence cannot be inferred from CORS header presence
func TestMiddlewareConsistentBehavior(t *testing.T) {
fallbackConfig := &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"*"},
},
},
}
testCases := []struct {
bucketExists bool
description string
}{
{bucketExists: true, description: "existing bucket"},
{bucketExists: false, description: "non-existent bucket"},
}
for _, tc := range testCases {
t.Run(tc.description, func(t *testing.T) {
// Setup mocks
bucketChecker := &mockBucketChecker{bucketExists: tc.bucketExists}
configGetter := &mockCORSConfigGetter{
config: nil,
errCode: s3err.ErrNone,
}
middleware := NewMiddleware(bucketChecker, configGetter, fallbackConfig)
// Create preflight request
req := httptest.NewRequest("OPTIONS", "/testbucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "testbucket",
"object": "testobject",
})
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
w := httptest.NewRecorder()
middleware.HandleOptionsRequest(w, req)
// Both existing and non-existent buckets should return same CORS headers
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
if actualOrigin != "https://example.com" {
t.Errorf("%s: expected Access-Control-Allow-Origin='https://example.com', got '%s'",
tc.description, actualOrigin)
}
// Both should return 200 OK for preflight
if w.Code != http.StatusOK {
t.Errorf("%s: expected status 200, got %d", tc.description, w.Code)
}
})
}
}

View File

@@ -0,0 +1,564 @@
package cors
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// Mock implementations for testing
type mockBucketChecker struct {
bucketExists bool
}
func (m *mockBucketChecker) CheckBucket(r *http.Request, bucket string) s3err.ErrorCode {
if m.bucketExists {
return s3err.ErrNone
}
return s3err.ErrNoSuchBucket
}
type mockCORSConfigGetter struct {
config *CORSConfiguration
errCode s3err.ErrorCode
}
func (m *mockCORSConfigGetter) GetCORSConfiguration(bucket string) (*CORSConfiguration, s3err.ErrorCode) {
return m.config, m.errCode
}
// TestMiddlewareFallbackConfig tests that the middleware uses fallback config when bucket-level config is not available
func TestMiddlewareFallbackConfig(t *testing.T) {
tests := []struct {
name string
bucketConfig *CORSConfiguration
fallbackConfig *CORSConfiguration
requestOrigin string
requestMethod string
isOptions bool
expectedStatus int
expectedOriginHeader string
description string
}{
{
name: "No bucket config, fallback to global config with wildcard",
bucketConfig: nil,
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://example.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://example.com",
description: "Should use fallback global config when no bucket config exists",
},
{
name: "No bucket config, fallback to global config with specific origin",
bucketConfig: nil,
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://example.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://example.com",
description: "Should use fallback config with specific origin match",
},
{
name: "No bucket config, fallback rejects non-matching origin",
bucketConfig: nil,
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://allowed.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://notallowed.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusOK,
expectedOriginHeader: "",
description: "Should not apply CORS headers when origin doesn't match fallback config",
},
{
name: "Bucket config takes precedence over fallback",
bucketConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://bucket-specific.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"*"},
},
},
},
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://bucket-specific.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://bucket-specific.com",
description: "Bucket-level config should be used instead of fallback",
},
{
name: "Bucket config rejects, even though fallback would allow",
bucketConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://restricted.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"*"},
},
},
},
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://example.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusOK,
expectedOriginHeader: "",
description: "Bucket-level config is authoritative, fallback should not apply",
},
{
name: "No config at all, no CORS headers",
bucketConfig: nil,
fallbackConfig: nil,
requestOrigin: "https://example.com",
requestMethod: "GET",
isOptions: false,
expectedStatus: http.StatusOK,
expectedOriginHeader: "",
description: "Without any config, no CORS headers should be applied",
},
{
name: "OPTIONS preflight with fallback config",
bucketConfig: nil,
fallbackConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
},
requestOrigin: "https://example.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusOK,
expectedOriginHeader: "https://example.com",
description: "OPTIONS preflight should work with fallback config",
},
{
name: "OPTIONS preflight without any config should fail",
bucketConfig: nil,
fallbackConfig: nil,
requestOrigin: "https://example.com",
requestMethod: "OPTIONS",
isOptions: true,
expectedStatus: http.StatusForbidden,
expectedOriginHeader: "",
description: "OPTIONS preflight without config should return 403",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mocks
bucketChecker := &mockBucketChecker{bucketExists: true}
configGetter := &mockCORSConfigGetter{
config: tt.bucketConfig,
errCode: s3err.ErrNone,
}
// Create middleware with optional fallback
middleware := NewMiddleware(bucketChecker, configGetter, tt.fallbackConfig)
// Create request with mux variables
req := httptest.NewRequest(tt.requestMethod, "/testbucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "testbucket",
"object": "testobject",
})
if tt.requestOrigin != "" {
req.Header.Set("Origin", tt.requestOrigin)
}
if tt.isOptions {
req.Header.Set("Access-Control-Request-Method", "GET")
}
// Create response recorder
w := httptest.NewRecorder()
// Create a simple handler that returns 200 OK
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Execute middleware
if tt.isOptions {
middleware.HandleOptionsRequest(w, req)
} else {
middleware.Handler(nextHandler).ServeHTTP(w, req)
}
// Check status code
if w.Code != tt.expectedStatus {
t.Errorf("%s: expected status %d, got %d", tt.description, tt.expectedStatus, w.Code)
}
// Check CORS header
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
if actualOrigin != tt.expectedOriginHeader {
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
tt.description, tt.expectedOriginHeader, actualOrigin)
}
})
}
}
// TestMiddlewareFallbackConfigWithMultipleOrigins tests fallback with multiple allowed origins
func TestMiddlewareFallbackConfigWithMultipleOrigins(t *testing.T) {
fallbackConfig := &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://example1.com", "https://example2.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
}
bucketChecker := &mockBucketChecker{bucketExists: true}
configGetter := &mockCORSConfigGetter{
config: nil, // No bucket config
errCode: s3err.ErrNone,
}
middleware := NewMiddleware(bucketChecker, configGetter, fallbackConfig)
tests := []struct {
origin string
shouldMatch bool
description string
}{
{
origin: "https://example1.com",
shouldMatch: true,
description: "First allowed origin should match",
},
{
origin: "https://example2.com",
shouldMatch: true,
description: "Second allowed origin should match",
},
{
origin: "https://example3.com",
shouldMatch: false,
description: "Non-allowed origin should not match",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "testbucket",
"object": "testobject",
})
req.Header.Set("Origin", tt.origin)
w := httptest.NewRecorder()
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
middleware.Handler(nextHandler).ServeHTTP(w, req)
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
if tt.shouldMatch {
if actualOrigin != tt.origin {
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
tt.description, tt.origin, actualOrigin)
}
} else {
if actualOrigin != "" {
t.Errorf("%s: expected no Access-Control-Allow-Origin header, got '%s'",
tt.description, actualOrigin)
}
}
})
}
}
// TestMiddlewareFallbackWithError tests that real errors (not "no config") don't trigger fallback
func TestMiddlewareFallbackWithError(t *testing.T) {
fallbackConfig := &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
}
tests := []struct {
name string
errCode s3err.ErrorCode
expectedOriginHeader string
description string
}{
{
name: "ErrAccessDenied should not trigger fallback",
errCode: s3err.ErrAccessDenied,
expectedOriginHeader: "",
description: "Access denied errors should not expose CORS headers",
},
{
name: "ErrInternalError should not trigger fallback",
errCode: s3err.ErrInternalError,
expectedOriginHeader: "",
description: "Internal errors should not expose CORS headers",
},
{
name: "ErrNoSuchBucket should trigger fallback",
errCode: s3err.ErrNoSuchBucket,
expectedOriginHeader: "https://example.com",
description: "Bucket not found errors should expose CORS headers to prevent information disclosure",
},
{
name: "ErrNoSuchCORSConfiguration should trigger fallback",
errCode: s3err.ErrNoSuchCORSConfiguration,
expectedOriginHeader: "https://example.com",
description: "Explicit no CORS config should use fallback",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bucketChecker := &mockBucketChecker{bucketExists: true}
configGetter := &mockCORSConfigGetter{
config: nil,
errCode: tt.errCode,
}
middleware := NewMiddleware(bucketChecker, configGetter, fallbackConfig)
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "testbucket",
"object": "testobject",
})
req.Header.Set("Origin", "https://example.com")
w := httptest.NewRecorder()
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
middleware.Handler(nextHandler).ServeHTTP(w, req)
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
if actualOrigin != tt.expectedOriginHeader {
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
tt.description, tt.expectedOriginHeader, actualOrigin)
}
})
}
}
// TestMiddlewareVaryHeader tests that the Vary: Origin header is correctly applied in various scenarios
func TestMiddlewareVaryHeader(t *testing.T) {
tests := []struct {
name string
bucketConfig *CORSConfiguration
shouldHaveVaryHeader bool
description string
}{
{
name: "Specific allowed origin",
bucketConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"*"},
},
},
},
shouldHaveVaryHeader: true,
description: "Should have Vary: Origin header when CORS config exists with specific origin",
},
{
name: "Wildcard allowed origin",
bucketConfig: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"*"},
},
},
},
shouldHaveVaryHeader: true,
description: "Should have Vary: Origin header even when CORS config has wildcard origin",
},
{
name: "No CORS configuration",
bucketConfig: nil,
shouldHaveVaryHeader: false,
description: "Should NOT have Vary: Origin header when no CORS config exists",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mocks
bucketChecker := &mockBucketChecker{bucketExists: true}
var errCode s3err.ErrorCode
if tt.bucketConfig == nil {
errCode = s3err.ErrNoSuchCORSConfiguration
} else {
errCode = s3err.ErrNone
}
configGetter := &mockCORSConfigGetter{
config: tt.bucketConfig,
errCode: errCode,
}
// Create middleware
middleware := NewMiddleware(bucketChecker, configGetter, nil)
// Create request WITHOUT Origin header
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "testbucket",
"object": "testobject",
})
// Create response recorder
w := httptest.NewRecorder()
// Create a simple handler that returns 200 OK
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Execute middleware
middleware.Handler(nextHandler).ServeHTTP(w, req)
// Check Vary header
hasVaryOrigin := hasVaryOrigin(w.Header())
if tt.shouldHaveVaryHeader && !hasVaryOrigin {
t.Errorf("%s: expected Vary: Origin header, but got %v", tt.description, w.Header()["Vary"])
} else if !tt.shouldHaveVaryHeader && hasVaryOrigin {
t.Errorf("%s: expected NO Vary: Origin header, but got it", tt.description)
}
})
}
}
// TestHandleOptionsRequestVaryHeader reproduces the issue where HandleOptionsRequest misses Vary: Origin
func TestHandleOptionsRequestVaryHeader(t *testing.T) {
// Setup mocks
bucketChecker := &mockBucketChecker{bucketExists: true}
config := &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"*"},
},
},
}
configGetter := &mockCORSConfigGetter{
config: config,
errCode: s3err.ErrNone,
}
// Create middleware
middleware := NewMiddleware(bucketChecker, configGetter, nil)
// Create OPTIONS request
req := httptest.NewRequest("OPTIONS", "/testbucket/testobject", nil)
req = mux.SetURLVars(req, map[string]string{
"bucket": "testbucket",
"object": "testobject",
})
// Set valid CORS headers
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
// Create response recorder
w := httptest.NewRecorder()
// Execute HandleOptionsRequest
middleware.HandleOptionsRequest(w, req)
// Check Response Status
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check Vary header - verified to FAIL without fix
if !hasVaryOrigin(w.Header()) {
t.Errorf("Expected Vary: Origin header in OPTIONS response, but got %v", w.Header()["Vary"])
}
}
// hasVaryOrigin checks if the "Vary" header contains "Origin"
func hasVaryOrigin(header http.Header) bool {
varyHeaders := header["Vary"]
for _, h := range varyHeaders {
for _, v := range strings.Split(h, ",") {
if strings.TrimSpace(v) == "Origin" {
return true
}
}
}
return false
}

226
weed/s3api/crc_combine.go Normal file
View File

@@ -0,0 +1,226 @@
package s3api
import "math"
// crcParams describes a CRC algorithm in the Rocksoft/Williams parameterization (see "15. A
// Parameterized Model For CRC Algorithms" in http://www.ross.net/crc/download/crc_v3.txt)
type crcParams struct {
width uint32 // the width of the algorithm expressed in bits; 1 less than the width of poly
poly uint64 // the unreflected poly
init uint64 // initial register value
xorout uint64 // value XORed into the final register
refin bool // reflect input bytes
refout bool // reflect final register
}
var crcCombineParams = map[ChecksumAlgorithm]crcParams{
ChecksumAlgorithmCRC64NVMe: {
width: 64,
poly: 0xad93d23594c93659,
init: 0xffffffffffffffff,
xorout: 0xffffffffffffffff,
refin: true,
refout: true,
},
ChecksumAlgorithmCRC32: {
width: 32,
poly: 0x04c11db7,
init: 0xffffffff,
xorout: 0xffffffff,
refin: true,
refout: true,
},
ChecksumAlgorithmCRC32C: {
width: 32,
poly: 0x1edc6f41,
init: 0xffffffff,
xorout: 0xffffffff,
refin: true,
refout: true,
},
}
// Ported from: https://github.com/awesomized/crc-fast-rust/blob/3a853cc7daf2cd47cc4466f198680cabdfb0b5fa/src/combine.rs
/*
Derived from this excellent answer by Mark Adler on StackOverflow:
https://stackoverflow.com/questions/29915764/generic-crc-8-16-32-64-combine-implementation/29928573#29928573
*/
/* crccomb.c -- generalized combination of CRCs
* Copyright (C) 2015 Mark Adler
* Version 1.1 29 Apr 2015 Mark Adler
*/
/*
This software is provided 'as-is', without any express or implied
warranty. In no event will the author be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Mark Adler
madler@alumni.caltech.edu
*/
/*
zlib provides a fast operation to combine the CRCs of two sequences of bytes
into a single CRC, which is the CRC of the two sequences concatenated. That
operation requires only the two CRC's and the length of the second sequence.
The routine in zlib only works on the particular CRC-32 used by zlib. The
code provided here generalizes that operation to apply to a wide range of
CRCs. The CRC is specified in a series of #defines, based on the
parameterization found in Ross William's excellent CRC tutorial here:
http://www.ross.net/crc/download/crc_v3.txt
A comprehensive catalogue of known CRCs, their parameters, check values, and
references can be found here:
http://reveng.sourceforge.net/crc-catalogue/all.htm
*/
// Multiply the GF(2) vector vec by the GF(2) matrix mat, returning the
// resulting vector. The vector is stored as bits in a crc_t. The matrix is
// similarly stored with each column as a crc_t, where the number of columns is
// at least enough to cover the position of the most significant 1 bit in the
// vector (so a dimension parameter is not needed).
func gf2MatrixTimes(mat *[64]uint64, vec uint64) uint64 {
var sum uint64
idx := 0
for vec > 0 {
if vec&1 == 1 {
sum ^= mat[idx]
}
vec >>= 1
idx++
}
return sum
}
// Multiply the matrix mat by itself, returning the result in square. WIDTH is
// the dimension of the matrices, i.e., the number of bits in each crc_t
// (rows), and the number of crc_t's (columns).
func gf2MatrixSquare(square *[64]uint64, mat *[64]uint64) {
for n := 0; n < 64; n++ {
square[n] = gf2MatrixTimes(mat, mat[n])
}
}
// Combine the CRCs of two successive sequences, where crc1 is the CRC of the
// first sequence of bytes, crc2 is the CRC of the immediately following
// sequence of bytes, and len2 is the length of the second sequence. The CRC
// of the combined sequence is returned.
func combineCRC(crc1 uint64, crc2 uint64, len2 uint64, params crcParams) uint64 {
even := [64]uint64{} /* even-power-of-two zeros operator */
odd := [64]uint64{} /* odd-power-of-two zeros operator */
/* exclusive-or the result with len2 zeros applied to the CRC of an empty
sequence */
crc1 ^= params.init ^ params.xorout
/* construct the operator for one zero bit and put in odd[] */
if params.refin && params.refout {
// use the reflected POLY
odd[0] = reflectPoly(params.poly, params.width)
col := uint64(1)
for n := uint32(1); n < params.width; n++ {
odd[n] = col
col <<= 1
}
} else if !params.refin && !params.refout {
col := uint64(2)
for n := uint32(0); n < params.width-1; n++ {
odd[n] = col
col <<= 1
}
odd[params.width-1] = params.poly
} else {
panic("Unsupported CRC configuration")
}
/* put operator for two zero bits in even */
gf2MatrixSquare(&even, &odd)
/* put operator for four zero bits in odd */
gf2MatrixSquare(&odd, &even)
/* apply len2 zeros to crc1 (first square will put the operator for one
zero byte, eight zero bits, in even) */
for {
/* apply zeros operator for this bit of len2 */
gf2MatrixSquare(&even, &odd)
if len2&1 == 1 {
crc1 = gf2MatrixTimes(&even, crc1)
}
len2 >>= 1
/* if no more bits set, then done */
if len2 == 0 {
break
}
/* another iteration of the loop with odd and even swapped */
gf2MatrixSquare(&odd, &even)
if len2&1 == 1 {
crc1 = gf2MatrixTimes(&odd, crc1)
}
len2 >>= 1
/* if no more bits set, then done */
if len2 == 0 {
break
}
}
/* return combined crc */
return crc1 ^ crc2
}
func reflectPoly(poly uint64, width uint32) uint64 {
if width > 64 {
panic("Width must be <= 64 bits")
}
// First reverse all bits
reversed := bitReverse(poly)
// Shift right to get the significant bits in the correct position
// For a 32-bit poly, we need to shift right by (64 - 32) = 32 bits
shifted := reversed >> (64 - width)
// Create mask for the target width
var mask uint64
if width == 64 {
mask = math.MaxUint64
} else {
mask = (1 << width) - 1
}
// Apply mask to ensure we only keep the bits we want
return shifted & mask
}
func bitReverse(forward uint64) uint64 {
reversed := uint64(0)
for i := 0; i < 64; i++ {
reversed <<= 1
reversed |= forward & 1
forward >>= 1
}
return reversed
}

View File

@@ -0,0 +1,124 @@
package s3api
import (
"bytes"
"hash/crc32"
"testing"
"github.com/minio/crc64nvme"
)
// Tests that combining per-block CRC-64/NVME values reproduces the CRC of the concatenated data.
func TestCombineCRC64NVME(t *testing.T) {
params := crcCombineParams[ChecksumAlgorithmCRC64NVMe]
blocks := [][]byte{
[]byte("abc"),
[]byte("def"),
[]byte("hello world, this is a longer block of data to combine"),
bytes.Repeat([]byte{0x00}, 5*1024*1024),
bytes.Repeat([]byte{0xAB, 0xCD}, 1024),
[]byte("z"),
}
var combined uint64
var concatenated []byte
for i, block := range blocks {
blockCRC := crc64nvme.Checksum(block)
if i == 0 {
combined = blockCRC
} else {
combined = combineCRC(combined, blockCRC, uint64(len(block)), params)
}
concatenated = append(concatenated, block...)
want := crc64nvme.Checksum(concatenated)
if combined != want {
t.Fatalf("after block %d: combined = %016x, want %016x", i, combined, want)
}
}
}
// Tests that combining per-block CRC-32 values reproduces the CRC of the concatenated data.
func TestCombineCRC32(t *testing.T) {
params := crcCombineParams[ChecksumAlgorithmCRC32]
blocks := [][]byte{
[]byte("abc"),
[]byte("def"),
[]byte("hello world, this is a longer block of data to combine"),
bytes.Repeat([]byte{0x00}, 5*1024*1024),
bytes.Repeat([]byte{0xAB, 0xCD}, 1024),
[]byte("z"),
}
var combined uint64
var concatenated []byte
for i, block := range blocks {
blockCRC := uint64(crc32.Checksum(block, crc32.MakeTable(crc32.IEEE)))
if i == 0 {
combined = blockCRC
} else {
combined = combineCRC(combined, blockCRC, uint64(len(block)), params)
}
concatenated = append(concatenated, block...)
want := uint64(crc32.Checksum(concatenated, crc32.MakeTable(crc32.IEEE)))
if combined != want {
t.Fatalf("after block %d: combined = %016x, want %016x", i, combined, want)
}
}
}
// Tests that combining per-block CRC-32C values reproduces the CRC of the concatenated data.
func TestCombineCRC32C(t *testing.T) {
params := crcCombineParams[ChecksumAlgorithmCRC32C]
table := crc32.MakeTable(crc32.Castagnoli)
blocks := [][]byte{
[]byte("abc"),
[]byte("def"),
[]byte("hello world, this is a longer block of data to combine"),
bytes.Repeat([]byte{0x00}, 5*1024*1024),
bytes.Repeat([]byte{0xAB, 0xCD}, 1024),
[]byte("z"),
}
var combined uint64
var concatenated []byte
for i, block := range blocks {
blockCRC := uint64(crc32.Checksum(block, table))
if i == 0 {
combined = blockCRC
} else {
combined = combineCRC(combined, blockCRC, uint64(len(block)), params)
}
concatenated = append(concatenated, block...)
want := uint64(crc32.Checksum(concatenated, table))
if combined != want {
t.Fatalf("after block %d: combined = %016x, want %016x", i, combined, want)
}
}
}
func TestCombineCRC64NVMEEmptySecond(t *testing.T) {
params := crcCombineParams[ChecksumAlgorithmCRC64NVMe]
crc1 := crc64nvme.Checksum([]byte("abc"))
if got := combineCRC(crc1, crc64nvme.Checksum(nil), 0, params); got != crc1 {
t.Fatalf("combining with empty second block changed crc: got %016x, want %016x", got, crc1)
}
}
func TestCombineCRC32EmptySecond(t *testing.T) {
params := crcCombineParams[ChecksumAlgorithmCRC32]
crc1 := uint64(crc32.Checksum([]byte("abc"), crc32.MakeTable(crc32.IEEE)))
if got := combineCRC(crc1, uint64(crc32.Checksum(nil, crc32.MakeTable(crc32.IEEE))), 0, params); got != crc1 {
t.Fatalf("combining with empty second block changed crc: got %016x, want %016x", got, crc1)
}
}

View File

@@ -0,0 +1,27 @@
package s3api
import (
"errors"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/seaweedfs/seaweedfs/weed/credential"
)
// CredentialErrToIamErrCode maps a credential store error to the appropriate
// IAM error code. Permanent client errors (e.g. duplicate entity, missing
// entity) are mapped to their specific IAM codes; all other errors fall back
// to ServiceFailure so the caller receives an HTTP 500 rather than a 200.
//
// This is shared between the standalone IAM server (weed/iamapi) and the
// embedded IAM handler (weed/s3api) so error granularity stays in sync.
func CredentialErrToIamErrCode(err error) string {
switch {
case errors.Is(err, credential.ErrUserAlreadyExists):
return iam.ErrCodeEntityAlreadyExistsException
case errors.Is(err, credential.ErrUserNotFound),
errors.Is(err, credential.ErrAccessKeyNotFound):
return iam.ErrCodeNoSuchEntityException
default:
return iam.ErrCodeServiceFailureException
}
}

View File

@@ -0,0 +1,13 @@
package s3api
import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// ConditionalHeaderResult holds the result of conditional header checking
type ConditionalHeaderResult struct {
ErrorCode s3err.ErrorCode
ETag string // ETag of the object (for 304 responses)
Entry *filer_pb.Entry // Entry fetched during conditional check (nil if not fetched or object doesn't exist)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
package s3api
import (
"bytes"
"encoding/base64"
"hash/crc32"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/minio/crc64nvme"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
func makeCRC64NVMEPartEntry(data []byte) *filer_pb.Entry {
sum := crc64nvme.New()
sum.Write(data)
return &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(data))},
Extended: map[string][]byte{
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC64NVME),
s3_constants.ExtChecksumValue: []byte(base64.StdEncoding.EncodeToString(sum.Sum(nil))),
},
}
}
func makeCRC32PartEntry(data []byte) *filer_pb.Entry {
sum := crc32.NewIEEE()
sum.Write(data)
return &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(data))},
Extended: map[string][]byte{
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC32),
s3_constants.ExtChecksumValue: []byte(base64.StdEncoding.EncodeToString(sum.Sum(nil))),
},
}
}
// Tests the FULL_OBJECT checksum of a multipart upload equals the CRC64NVME of the concatenated
// part data, with no composite "-N" suffix.
func TestComputeFullObjectChecksumCRC64NVME(t *testing.T) {
parts := [][]byte{
bytes.Repeat([]byte("a"), 5*1024*1024),
bytes.Repeat([]byte("b"), 5*1024*1024),
[]byte("tail part, smaller than the rest"),
}
partEntries := map[int][]*filer_pb.Entry{}
var whole []byte
completed := []int{}
for i, data := range parts {
partNumber := i + 1
partEntries[partNumber] = []*filer_pb.Entry{makeCRC64NVMEPartEntry(data)}
completed = append(completed, partNumber)
whole = append(whole, data...)
}
got, err := computeFullObjectChecksum(s3_constants.AmzChecksumCRC64NVME, partEntries, completed)
if err != nil {
t.Fatalf("computeFullObjectChecksum: %v", err)
}
wholeSum := crc64nvme.New()
wholeSum.Write(whole)
expected := base64.StdEncoding.EncodeToString(wholeSum.Sum(nil))
if got != expected {
t.Fatalf("full object checksum = %q, want %q", got, expected)
}
if bytes.ContainsRune([]byte(got), '-') {
t.Fatalf("full object checksum must not carry a -N suffix: %q", got)
}
}
// Tests the FULL_OBJECT checksum of a multipart upload equals the CRC32 of the concatenated
// part data, with no composite "-N" suffix.
func TestComputeFullObjectChecksumCRC32(t *testing.T) {
parts := [][]byte{
bytes.Repeat([]byte("a"), 5*1024*1024),
bytes.Repeat([]byte("b"), 5*1024*1024),
[]byte("tail part, smaller than the rest"),
}
partEntries := map[int][]*filer_pb.Entry{}
var whole []byte
completed := []int{}
for i, data := range parts {
partNumber := i + 1
partEntries[partNumber] = []*filer_pb.Entry{makeCRC32PartEntry(data)}
completed = append(completed, partNumber)
whole = append(whole, data...)
}
got, err := computeFullObjectChecksum(s3_constants.AmzChecksumCRC32, partEntries, completed)
if err != nil {
t.Fatalf("computeFullObjectChecksum: %v", err)
}
wholeSum := crc32.NewIEEE()
wholeSum.Write(whole)
expected := base64.StdEncoding.EncodeToString(wholeSum.Sum(nil))
if got != expected {
t.Fatalf("full object checksum = %q, want %q", got, expected)
}
if bytes.ContainsRune([]byte(got), '-') {
t.Fatalf("full object checksum must not carry a -N suffix: %q", got)
}
}
func TestResolveMultipartChecksumType(t *testing.T) {
cases := []struct {
name string
algo ChecksumAlgorithm
requested string
want string
wantErr bool
}{
{"crc64 default full", ChecksumAlgorithmCRC64NVMe, "", s3_constants.ChecksumTypeFullObject, false},
{"crc64 explicit full", ChecksumAlgorithmCRC64NVMe, s3_constants.ChecksumTypeFullObject, s3_constants.ChecksumTypeFullObject, false},
{"crc64 composite rejected", ChecksumAlgorithmCRC64NVMe, s3_constants.ChecksumTypeComposite, "", true},
{"crc32 default composite", ChecksumAlgorithmCRC32, "", s3_constants.ChecksumTypeComposite, false},
{"crc32 explicit composite", ChecksumAlgorithmCRC32, s3_constants.ChecksumTypeComposite, s3_constants.ChecksumTypeComposite, false},
{"crc32 explicit full", ChecksumAlgorithmCRC32, s3_constants.ChecksumTypeFullObject, s3_constants.ChecksumTypeFullObject, false},
{"crc32c explicit full", ChecksumAlgorithmCRC32C, s3_constants.ChecksumTypeFullObject, s3_constants.ChecksumTypeFullObject, false},
{"sha256 default composite", ChecksumAlgorithmSHA256, "", s3_constants.ChecksumTypeComposite, false},
{"sha256 full rejected", ChecksumAlgorithmSHA256, s3_constants.ChecksumTypeFullObject, "", true},
{"invalid checksum type", ChecksumAlgorithmCRC64NVMe, "bogus", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveMultipartChecksumType(tc.algo, tc.requested)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
// CompleteMultipartUpload returns the object checksum in the XML body, where the
// AWS SDKs read it from — not as an HTTP response header.
func TestCompleteMultipartUploadResultChecksumXML(t *testing.T) {
result := &CompleteMultipartUploadResult{
ETag: aws.String("\"etag-1\""),
ChecksumType: s3_constants.ChecksumTypeComposite,
}
result.SetChecksum(s3_constants.AmzChecksumCRC32C, "fx4FQw==-1")
encoded := string(s3err.EncodeXMLResponse(result))
for _, want := range []string{
"<ChecksumCRC32C>fx4FQw==-1</ChecksumCRC32C>",
"<ChecksumType>COMPOSITE</ChecksumType>",
} {
if !strings.Contains(encoded, want) {
t.Fatalf("response %q does not contain %q", encoded, want)
}
}
if strings.Contains(encoded, "<ChecksumCRC32>") {
t.Fatalf("response %q carries an unrequested algorithm", encoded)
}
}
// A retried CompleteMultipartUpload rebuilds the response from the committed
// entry, so it must repeat the checksum the first attempt returned.
func TestCompleteMultipartResultChecksumFromEntry(t *testing.T) {
input := &s3.CompleteMultipartUploadInput{Bucket: aws.String("bucket"), Key: aws.String("key")}
entry := &filer_pb.Entry{Extended: map[string][]byte{
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC32C),
s3_constants.ExtChecksumValue: []byte("fx4FQw==-1"),
s3_constants.ExtChecksumType: []byte(s3_constants.ChecksumTypeComposite),
}}
result := completeMultipartResult(httptest.NewRequest(http.MethodPost, "/bucket/key", nil), input, "\"etag-1\"", entry)
if result.ChecksumCRC32C != "fx4FQw==-1" {
t.Fatalf("ChecksumCRC32C = %q, want %q", result.ChecksumCRC32C, "fx4FQw==-1")
}
if result.ChecksumType != s3_constants.ChecksumTypeComposite {
t.Fatalf("ChecksumType = %q, want %q", result.ChecksumType, s3_constants.ChecksumTypeComposite)
}
}
// CreateMultipartUpload echoes the algorithm and type it recorded, so a client
// can tell which checksum the upload will be completed with.
func TestCreateMultipartUploadResultChecksumHeaders(t *testing.T) {
result := &InitiateMultipartUploadResult{
ChecksumAlgorithm: "CRC32C",
ChecksumType: s3_constants.ChecksumTypeComposite,
}
if encoded := string(s3err.EncodeXMLResponse(result)); strings.Contains(encoded, "Checksum") {
t.Fatalf("response %q carries checksum members in the XML body", encoded)
}
}

View File

@@ -0,0 +1,121 @@
// Tests multipart upload ETag behavior when both stored S3 ETags and
// MD5-derived ETags are present. The tests verify that SeaweedFS uses
// the stored ETag for part validation and multipart ETag calculation,
// while still falling back to the MD5-derived value when necessary.
package s3api
import (
"crypto/md5"
"encoding/hex"
"fmt"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func TestGetEtagFromEntryPrefersStoredExtendedETag(t *testing.T) {
storedETag := "11111111111111111111111111111111"
computedMD5 := mustDecodeHexETagForTest(t, "22222222222222222222222222222222")
entry := &filer_pb.Entry{
Name: "0001.part",
Attributes: &filer_pb.FuseAttributes{
Md5: computedMD5,
},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte(storedETag),
},
}
if got, want := getEtagFromEntry(entry), `"`+storedETag+`"`; got != want {
t.Fatalf("getEtagFromEntry() = %q, want stored extended ETag %q", got, want)
}
match, invalid, normalizedPartETag, normalizedEntryETag := validateCompletePartETag(storedETag, entry)
if invalid || !match {
t.Fatalf("validateCompletePartETag() = match:%v invalid:%v part:%q entry:%q, want stored ETag match", match, invalid, normalizedPartETag, normalizedEntryETag)
}
computedETag := hex.EncodeToString(computedMD5)
match, invalid, normalizedPartETag, normalizedEntryETag = validateCompletePartETag(computedETag, entry)
if invalid || match {
t.Fatalf("validateCompletePartETag() = match:%v invalid:%v part:%q entry:%q, want computed filer ETag mismatch", match, invalid, normalizedPartETag, normalizedEntryETag)
}
}
func TestGetEtagFromEntryFallbacksToFilerETag(t *testing.T) {
computedETag := "33333333333333333333333333333333"
entry := &filer_pb.Entry{
Name: "0001.part",
Attributes: &filer_pb.FuseAttributes{
Md5: mustDecodeHexETagForTest(t, computedETag),
},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte(""),
},
}
if got, want := getEtagFromEntry(entry), `"`+computedETag+`"`; got != want {
t.Fatalf("getEtagFromEntry() = %q, want fallback filer ETag %q", got, want)
}
}
func TestCalculateMultipartETagUsesStoredPartETags(t *testing.T) {
storedPart1 := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
storedPart2 := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
computedPart1 := "cccccccccccccccccccccccccccccccc"
computedPart2 := "dddddddddddddddddddddddddddddddd"
partEntries := map[int][]*filer_pb.Entry{
1: {newMultipartETagTestEntry(t, "0001.part", storedPart1, computedPart1)},
2: {newMultipartETagTestEntry(t, "0002.part", storedPart2, computedPart2)},
}
got := calculateMultipartETag(partEntries, []int{1, 2})
want := expectedMultipartETagForTest(t, storedPart1, storedPart2)
if got != want {
t.Fatalf("calculateMultipartETag() = %q, want %q", got, want)
}
legacyComputedETag := expectedMultipartETagForTest(t, computedPart1, computedPart2)
if got == legacyComputedETag {
t.Fatalf("calculateMultipartETag() = %q, still used filer.ETag-derived part ETags", got)
}
}
func newMultipartETagTestEntry(t *testing.T, name, storedETag, computedETag string) *filer_pb.Entry {
t.Helper()
return &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Md5: mustDecodeHexETagForTest(t, computedETag),
},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte(storedETag),
},
}
}
func expectedMultipartETagForTest(t *testing.T, partETags ...string) string {
t.Helper()
var combined []byte
for _, partETag := range partETags {
partETag = strings.Trim(partETag, `"`)
if before, _, found := strings.Cut(partETag, "-"); found {
partETag = before
}
combined = append(combined, mustDecodeHexETagForTest(t, partETag)...)
}
return fmt.Sprintf("%x-%d", md5.Sum(combined), len(partETags))
}
func mustDecodeHexETagForTest(t *testing.T, etag string) []byte {
t.Helper()
decoded, err := hex.DecodeString(etag)
if err != nil {
t.Fatalf("decode hex ETag %q: %v", etag, err)
}
return decoded
}

View File

@@ -0,0 +1,346 @@
package s3api
import (
"bytes"
"crypto/rand"
"encoding/base64"
"io"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func TestCompletedMultipartChunkBackfillsSSES3MetadataFromUploadEntry(t *testing.T) {
keyManager := initSSES3KeyManagerForTest(t)
key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("GenerateSSES3Key: %v", err)
}
keyData, err := SerializeSSES3Metadata(key)
if err != nil {
t.Fatalf("SerializeSSES3Metadata: %v", err)
}
baseIV := []byte("1234567890abcdef")
uploadEntry := &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.SeaweedFSSSES3Encryption: []byte(s3_constants.SSEAlgorithmAES256),
s3_constants.SeaweedFSSSES3BaseIV: []byte(base64.StdEncoding.EncodeToString(baseIV)),
s3_constants.SeaweedFSSSES3KeyData: []byte(base64.StdEncoding.EncodeToString(keyData)),
},
}
sses3Info, err := extractMultipartSSES3Info(uploadEntry)
if err != nil {
t.Fatalf("extractMultipartSSES3Info: %v", err)
}
partLocalOffset := int64(8 * 1024 * 1024)
finalObjectOffset := int64(40 * 1024 * 1024)
chunk := &filer_pb.FileChunk{
FileId: "1,abc",
Offset: partLocalOffset,
Size: 1024,
}
finalChunk, err := completedMultipartChunk(chunk, finalObjectOffset, sses3Info)
if err != nil {
t.Fatalf("completedMultipartChunk: %v", err)
}
if finalChunk.GetOffset() != finalObjectOffset {
t.Fatalf("final chunk offset = %d, want %d", finalChunk.GetOffset(), finalObjectOffset)
}
if finalChunk.GetSseType() != filer_pb.SSEType_SSE_S3 {
t.Fatalf("final chunk SSE type = %s, want SSE_S3", finalChunk.GetSseType())
}
if len(finalChunk.GetSseMetadata()) == 0 {
t.Fatal("expected final chunk SSE metadata")
}
chunkKey, err := DeserializeSSES3Metadata(finalChunk.GetSseMetadata(), keyManager)
if err != nil {
t.Fatalf("DeserializeSSES3Metadata: %v", err)
}
if !bytes.Equal(chunkKey.Key, key.Key) {
t.Fatal("chunk metadata key does not match upload key")
}
expectedIV, _ := calculateIVWithOffset(baseIV, partLocalOffset)
if !bytes.Equal(chunkKey.IV, expectedIV) {
t.Fatalf("chunk metadata IV = %x, want %x", chunkKey.IV, expectedIV)
}
}
func TestCompletedMultipartChunkPreservesExistingSSES3Metadata(t *testing.T) {
existingMetadata := []byte("already-present")
chunk := &filer_pb.FileChunk{
FileId: "2,abc",
Offset: 1024,
Size: 2048,
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: existingMetadata,
}
finalChunk, err := completedMultipartChunk(chunk, 4096, &multipartSSES3Info{})
if err != nil {
t.Fatalf("completedMultipartChunk: %v", err)
}
if !bytes.Equal(finalChunk.GetSseMetadata(), existingMetadata) {
t.Fatal("existing SSE-S3 metadata should be preserved")
}
}
// TestApplyMultipartSSES3HeadersFromUploadEntry pins the canonical object-level
// SSE-S3 attributes onto the completed entry: SeaweedFSSSES3Key (used by
// IsSSES3EncryptedInternal and the read-side decryption-key lookup) and
// X-Amz-Server-Side-Encryption (used by detectPrimarySSEType for non-chunked
// reads). Pre-existing values must not be clobbered.
func TestApplyMultipartSSES3HeadersFromUploadEntry(t *testing.T) {
initSSES3KeyManagerForTest(t)
key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("GenerateSSES3Key: %v", err)
}
keyData, err := SerializeSSES3Metadata(key)
if err != nil {
t.Fatalf("SerializeSSES3Metadata: %v", err)
}
baseIV := []byte("1234567890abcdef")
uploadEntry := &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.SeaweedFSSSES3Encryption: []byte(s3_constants.SSEAlgorithmAES256),
s3_constants.SeaweedFSSSES3BaseIV: []byte(base64.StdEncoding.EncodeToString(baseIV)),
s3_constants.SeaweedFSSSES3KeyData: []byte(base64.StdEncoding.EncodeToString(keyData)),
},
}
sses3Info, err := extractMultipartSSES3Info(uploadEntry)
if err != nil {
t.Fatalf("extractMultipartSSES3Info: %v", err)
}
t.Run("backfills missing canonical attributes", func(t *testing.T) {
finalEntry := &filer_pb.Entry{Extended: map[string][]byte{}}
applyMultipartSSES3HeadersFromUploadEntry(finalEntry, sses3Info)
if !bytes.Equal(finalEntry.Extended[s3_constants.SeaweedFSSSES3Key], keyData) {
t.Fatal("final entry did not receive object-level SSE-S3 key metadata")
}
if got := string(finalEntry.Extended[s3_constants.AmzServerSideEncryption]); got != s3_constants.SSEAlgorithmAES256 {
t.Fatalf("final entry SSE header = %q, want %q", got, s3_constants.SSEAlgorithmAES256)
}
})
t.Run("does not clobber existing canonical attributes", func(t *testing.T) {
existingKey := []byte("existing-key-bytes")
existingHeader := []byte("aws:kms")
finalEntry := &filer_pb.Entry{Extended: map[string][]byte{
s3_constants.SeaweedFSSSES3Key: existingKey,
s3_constants.AmzServerSideEncryption: existingHeader,
}}
applyMultipartSSES3HeadersFromUploadEntry(finalEntry, sses3Info)
if !bytes.Equal(finalEntry.Extended[s3_constants.SeaweedFSSSES3Key], existingKey) {
t.Fatal("existing SSE-S3 key was overwritten")
}
if !bytes.Equal(finalEntry.Extended[s3_constants.AmzServerSideEncryption], existingHeader) {
t.Fatal("existing X-Amz-Server-Side-Encryption was overwritten")
}
})
t.Run("nil info is a no-op", func(t *testing.T) {
finalEntry := &filer_pb.Entry{Extended: map[string][]byte{}}
applyMultipartSSES3HeadersFromUploadEntry(finalEntry, nil)
if len(finalEntry.Extended) != 0 {
t.Fatalf("nil sses3Info should not write any keys, got %d", len(finalEntry.Extended))
}
})
}
// TestCompletedMultipartChunkBackfilledIVDecryptsActualCiphertext is a round
// trip across the encryption boundary: it encrypts simulated multipart parts
// using the same call path putToFiler uses (CreateSSES3EncryptedReaderWithBaseIV
// with partOffset=0, then chunked at fixed boundaries), throws away the chunk
// level SSE metadata to simulate the bug from #8908, runs completedMultipartChunk
// to backfill it, and decrypts each chunk's slice of ciphertext with the
// backfilled IV. The plaintext must come back intact.
//
// Regression coverage for the gemini review on #9224 which suggested using
// (partNumber-1)*PartOffsetMultiplier + chunk.Offset for the backfill IV. That
// formula does not match what the encryption side actually does (PartOffsetMultiplier
// is defined but not used in the write path), so adopting it would silently
// produce backfilled IVs that fail to decrypt the bytes on disk.
func TestCompletedMultipartChunkBackfilledIVDecryptsActualCiphertext(t *testing.T) {
keyManager := initSSES3KeyManagerForTest(t)
key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("GenerateSSES3Key: %v", err)
}
keyData, err := SerializeSSES3Metadata(key)
if err != nil {
t.Fatalf("SerializeSSES3Metadata: %v", err)
}
baseIV := make([]byte, s3_constants.AESBlockSize)
if _, err := rand.Read(baseIV); err != nil {
t.Fatalf("rand.Read baseIV: %v", err)
}
uploadEntry := &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.SeaweedFSSSES3Encryption: []byte(s3_constants.SSEAlgorithmAES256),
s3_constants.SeaweedFSSSES3BaseIV: []byte(base64.StdEncoding.EncodeToString(baseIV)),
s3_constants.SeaweedFSSSES3KeyData: []byte(base64.StdEncoding.EncodeToString(keyData)),
},
}
sses3Info, err := extractMultipartSSES3Info(uploadEntry)
if err != nil {
t.Fatalf("extractMultipartSSES3Info: %v", err)
}
const chunkSize = int64(8 * 1024 * 1024)
// Two parts: part 1 spans three internal chunks (offsets 0, 8MB, 16MB),
// part 2 spans two (offsets 0, 8MB). Both parts are encrypted independently
// with partOffset=0, the way putToFiler invokes the encryption path today.
parts := []struct {
name string
plaintext []byte
}{
{"part1", makeRandomPlaintext(t, int(chunkSize)*3-1234)},
{"part2", makeRandomPlaintext(t, int(chunkSize)*2+5678)},
}
for _, p := range parts {
t.Run(p.name, func(t *testing.T) {
// Encrypt the part stream the same way handleSSES3MultipartEncryption does.
encReader, _, err := CreateSSES3EncryptedReaderWithBaseIV(bytes.NewReader(p.plaintext), key, baseIV, 0)
if err != nil {
t.Fatalf("CreateSSES3EncryptedReaderWithBaseIV: %v", err)
}
ciphertext, err := io.ReadAll(encReader)
if err != nil {
t.Fatalf("read encrypted stream: %v", err)
}
if len(ciphertext) != len(p.plaintext) {
t.Fatalf("ciphertext length %d != plaintext length %d", len(ciphertext), len(p.plaintext))
}
// Slice the encrypted stream into chunks the same way UploadReaderInChunks
// would, and build FileChunks tagged SseType=NONE to simulate the bug.
var bugChunks []*filer_pb.FileChunk
for off := int64(0); off < int64(len(ciphertext)); off += chunkSize {
end := off + chunkSize
if end > int64(len(ciphertext)) {
end = int64(len(ciphertext))
}
bugChunks = append(bugChunks, &filer_pb.FileChunk{
FileId: "1,deadbeef",
Offset: off,
Size: uint64(end - off),
// SseType deliberately left at default NONE.
})
}
// finalOffset doesn't influence the IV calculation, only the chunk's
// position in the assembled object. Use a fake non-zero value to
// confirm the function does not accidentally use it for the IV.
const finalOffset int64 = 1 << 40
for _, bugChunk := range bugChunks {
finalChunk, err := completedMultipartChunk(bugChunk, finalOffset+bugChunk.Offset, sses3Info)
if err != nil {
t.Fatalf("completedMultipartChunk(offset=%d): %v", bugChunk.Offset, err)
}
if finalChunk.GetSseType() != filer_pb.SSEType_SSE_S3 {
t.Fatalf("chunk@%d not retagged SSE_S3", bugChunk.Offset)
}
meta, err := DeserializeSSES3Metadata(finalChunk.GetSseMetadata(), keyManager)
if err != nil {
t.Fatalf("DeserializeSSES3Metadata(chunk@%d): %v", bugChunk.Offset, err)
}
cipherSlice := ciphertext[bugChunk.Offset : bugChunk.Offset+int64(bugChunk.Size)]
wantPlain := p.plaintext[bugChunk.Offset : bugChunk.Offset+int64(bugChunk.Size)]
decReader, err := CreateSSES3DecryptedReader(bytes.NewReader(cipherSlice), key, meta.IV)
if err != nil {
t.Fatalf("CreateSSES3DecryptedReader(chunk@%d): %v", bugChunk.Offset, err)
}
gotPlain, err := io.ReadAll(decReader)
if err != nil {
t.Fatalf("read decrypted chunk@%d: %v", bugChunk.Offset, err)
}
if !bytes.Equal(gotPlain, wantPlain) {
t.Fatalf("chunk@%d: backfilled IV produced wrong plaintext (first mismatch byte at %d)",
bugChunk.Offset, firstMismatch(gotPlain, wantPlain))
}
}
})
}
}
// TestCompletedMultipartChunkRejectsPartNumberMultiplierFormula is a guard
// against re-introducing the gemini review's suggested formula, which would
// produce IVs incompatible with the encryption side. The test simulates what
// would happen if the backfill used (partNumber-1)*PartOffsetMultiplier as an
// extra offset term: the resulting IV does not decrypt the actual ciphertext.
func TestCompletedMultipartChunkRejectsPartNumberMultiplierFormula(t *testing.T) {
key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("GenerateSSES3Key: %v", err)
}
baseIV := make([]byte, s3_constants.AESBlockSize)
if _, err := rand.Read(baseIV); err != nil {
t.Fatalf("rand.Read baseIV: %v", err)
}
plaintext := makeRandomPlaintext(t, 4096)
encReader, _, err := CreateSSES3EncryptedReaderWithBaseIV(bytes.NewReader(plaintext), key, baseIV, 0)
if err != nil {
t.Fatalf("CreateSSES3EncryptedReaderWithBaseIV: %v", err)
}
ciphertext, err := io.ReadAll(encReader)
if err != nil {
t.Fatalf("read encrypted stream: %v", err)
}
// "partNumber=2" with PartOffsetMultiplier added would shift the IV by 8GB.
wrongIV, _ := calculateIVWithOffset(baseIV, s3_constants.PartOffsetMultiplier+0)
decReader, err := CreateSSES3DecryptedReader(bytes.NewReader(ciphertext), key, wrongIV)
if err != nil {
t.Fatalf("CreateSSES3DecryptedReader: %v", err)
}
got, err := io.ReadAll(decReader)
if err != nil {
t.Fatalf("read decrypted: %v", err)
}
if bytes.Equal(got, plaintext) {
t.Fatal("partNumber-multiplier IV decrypted plaintext correctly; the backfill formula must NOT include this term")
}
}
func makeRandomPlaintext(t *testing.T, n int) []byte {
t.Helper()
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
t.Fatalf("rand.Read: %v", err)
}
return b
}
func firstMismatch(a, b []byte) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
return n
}

270
weed/s3api/filer_util.go Normal file
View File

@@ -0,0 +1,270 @@
package s3api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s3a *S3ApiServer) mkdir(parentDirectoryPath string, dirName string, fn func(entry *filer_pb.Entry)) error {
return filer_pb.Mkdir(context.Background(), s3a, parentDirectoryPath, dirName, fn)
}
func (s3a *S3ApiServer) mkFile(parentDirectoryPath string, fileName string, chunks []*filer_pb.FileChunk, fn func(entry *filer_pb.Entry)) error {
err := filer_pb.MkFile(context.Background(), s3a, parentDirectoryPath, fileName, chunks, fn)
if errors.Is(err, filer_pb.ErrExistingIsDirectory) && !isReservedDirectoryName(fileName) {
// Other keys are nested under this one, so the object goes onto the directory
// they live in - the same place a PutObject of this key writes it.
err = filer_pb.MkFile(context.Background(), s3a, parentDirectoryPath, fileName, chunks, func(entry *filer_pb.Entry) {
if fn != nil {
fn(entry)
}
entry.MarkPrefixObject()
})
}
return err
}
func (s3a *S3ApiServer) list(parentDirectoryPath, prefix, startFrom string, inclusive bool, limit uint32) (entries []*filer_pb.Entry, isLast bool, err error) {
return listWithRetry(parentDirectoryPath, func() (entries []*filer_pb.Entry, isLast bool, err error) {
err = filer_pb.List(context.Background(), s3a, parentDirectoryPath, prefix, func(entry *filer_pb.Entry, isLastEntry bool) error {
entries = append(entries, entry)
if isLastEntry {
isLast = true
}
return nil
}, startFrom, inclusive, limit)
if len(entries) == 0 {
isLast = true
}
return
})
}
// A listing has no side effects and collects into a fresh slice per attempt, so
// a replay can neither duplicate nor drop entries; the bound caps a filer that
// is genuinely down at two extra attempts and 300ms of added wait.
const (
listRetryAttempts = 3
listRetryInitialBackoff = 100 * time.Millisecond
)
// isRetryableListError defers to util.IsTransientError, which reads the status
// the filer sent rather than the message DoSeaweedListWithSnapshot builds around
// it out of the bucket and prefix the client chose. Not-found is authoritative
// and must reach the caller unchanged.
func isRetryableListError(err error) bool {
return err != nil && !isFilerNotFound(err) && util.IsTransientError(err)
}
// listWithRetry replays doList while the filer answers with a transient error.
// Both failure points, the ListEntries call itself and the stream.Recv that
// follows it, surface as a plain error out of filer_pb.List, so a single retry
// point above it covers both.
func listWithRetry(parentDirectoryPath string, doList func() (entries []*filer_pb.Entry, isLast bool, err error)) (entries []*filer_pb.Entry, isLast bool, err error) {
backoff := listRetryInitialBackoff
for attempt := 1; ; attempt++ {
entries, isLast, err = doList()
if err == nil || attempt >= listRetryAttempts || !isRetryableListError(err) {
return entries, isLast, err
}
glog.V(1).Infof("list %s attempt %d/%d hit a transient error, retrying in %v: %v", parentDirectoryPath, attempt, listRetryAttempts, backoff, err)
time.Sleep(backoff)
backoff *= 2
}
}
// A delete is idempotent at the filer, which answers an entry that is already
// gone with an empty resp.Error, so a reply the transport dropped can be
// reissued instead of surfaced: as a 500 on the bucket delete, or as a per-key
// InternalError inside the 200 of a multi-object delete, which no SDK retries.
// Each attempt re-enters WithFilerClient, so it walks the failover list again
// on a connection the failed one had invalidated.
func (s3a *S3ApiServer) rm(ctx context.Context, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
return retryFilerOp(ctx, "rm "+parentDirectoryPath+"/"+entryName, func() error {
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return doDeleteEntry(ctx, client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
})
})
}
func (s3a *S3ApiServer) rmObject(ctx context.Context, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
return retryFilerOp(ctx, "rmObject "+parentDirectoryPath+"/"+entryName, func() error {
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return deleteObjectEntry(ctx, client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
})
})
}
func deleteObjectEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
err := doDeleteEntry(ctx, client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
if err == nil {
return nil
}
if !errors.Is(err, filer.ErrNonEmptyFolder) {
return err
}
return demoteDirectoryMarkerToImplicitDirectory(ctx, client, parentDirectoryPath, entryName)
}
func doDeleteEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, parentDirectoryPath string, entryName string, isDeleteData bool, isRecursive bool) error {
request := &filer_pb.DeleteEntryRequest{
Directory: parentDirectoryPath,
Name: entryName,
IsDeleteData: isDeleteData,
IsRecursive: isRecursive,
IgnoreRecursiveError: true,
}
glog.V(1).Infof("delete entry %v/%v: %v", parentDirectoryPath, entryName, request)
if resp, err := client.DeleteEntry(ctx, request); err != nil {
glog.V(1).Infof("delete entry %v: %v", request, err)
return fmt.Errorf("delete entry %s/%s: %w", parentDirectoryPath, entryName, err)
} else {
if resp.Error != "" {
// the path wrapped in here is the client's, so classify the filer's
// text now, while it still stands alone
return fmt.Errorf("delete entry %s/%s: %w", parentDirectoryPath, entryName, filer.DeleteEntryError(resp.Error))
}
}
return nil
}
func demoteDirectoryMarkerToImplicitDirectory(ctx context.Context, client filer_pb.SeaweedFilerClient, parentDirectoryPath, entryName string) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDirectoryPath,
Name: entryName,
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return fmt.Errorf("lookup entry %s/%s: %w", parentDirectoryPath, entryName, err)
}
if resp.Entry == nil || !resp.Entry.IsDirectory {
return nil
}
if !resp.Entry.IsDirectoryKeyObject() {
return nil
}
clearDirectoryMarkerMetadata(resp.Entry)
if err := filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
Directory: parentDirectoryPath,
Entry: resp.Entry,
}); err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound {
return nil
}
return fmt.Errorf("update entry %s/%s: %w", parentDirectoryPath, entryName, err)
}
return nil
}
func clearDirectoryMarkerMetadata(entry *filer_pb.Entry) {
if entry == nil {
return
}
if entry.Attributes == nil {
entry.Attributes = &filer_pb.FuseAttributes{}
}
entry.Attributes.Mime = ""
entry.Attributes.Md5 = nil
entry.Attributes.FileSize = 0
entry.Content = nil
entry.Chunks = nil
if len(entry.Extended) == 0 {
return
}
filtered := make(map[string][]byte)
for k, v := range entry.Extended {
lowerKey := strings.ToLower(k)
if lowerKey == s3_constants.SeaweedFSPrefixObject {
// The path is a plain directory again, not a key of its own.
continue
}
if strings.HasPrefix(lowerKey, "xattr-") || strings.HasPrefix(lowerKey, s3_constants.SeaweedFSInternalPrefix) {
filtered[k] = v
}
}
if len(filtered) == 0 {
entry.Extended = nil
return
}
entry.Extended = filtered
}
func (s3a *S3ApiServer) exists(parentDirectoryPath string, entryName string, isDirectory bool) (exists bool, err error) {
return filer_pb.Exists(context.Background(), s3a, parentDirectoryPath, entryName, isDirectory)
}
func (s3a *S3ApiServer) getEntry(parentDirectoryPath, entryName string) (entry *filer_pb.Entry, err error) {
fullPath := util.NewFullPath(parentDirectoryPath, entryName)
entry, _, _, err = filer_pb.GetEntry(context.Background(), s3a, fullPath)
return entry, err
}
func (s3a *S3ApiServer) updateEntry(parentDirectoryPath string, newEntry *filer_pb.Entry) error {
updateEntryRequest := &filer_pb.UpdateEntryRequest{
Directory: parentDirectoryPath,
Entry: newEntry,
}
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
err := filer_pb.UpdateEntry(context.Background(), client, updateEntryRequest)
if err != nil {
return err
}
return nil
})
return err
}
func (s3a *S3ApiServer) getCollectionName(bucket string) string {
if s3a.option.FilerGroup != "" {
return fmt.Sprintf("%s_%s", s3a.option.FilerGroup, bucket)
}
return bucket
}
func objectKey(key *string) *string {
if strings.HasPrefix(*key, "/") {
t := (*key)[1:]
return &t
}
return key
}

View File

@@ -0,0 +1,338 @@
package s3api
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
grpc "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type deleteObjectEntryTestClient struct {
filer_pb.SeaweedFilerClient
deleteResp *filer_pb.DeleteEntryResponse
deleteErr error
lookupResp *filer_pb.LookupDirectoryEntryResponse
lookupErr error
updateErr error
// deleteCallErrs scripts the RPC itself per attempt, deleteRespErrs the
// filer's own message: the two ways a delete fails, told apart because
// only the first is a transport that dropped a reply.
deleteCallErrs []error
deleteRespErrs []string
deleteAttempts int
deleteReqs []*filer_pb.DeleteEntryRequest
deleteReq *filer_pb.DeleteEntryRequest
lookupReq *filer_pb.LookupDirectoryEntryRequest
updateReq *filer_pb.UpdateEntryRequest
}
func (c *deleteObjectEntryTestClient) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
attempt := c.deleteAttempts
c.deleteAttempts++
c.deleteReq = req
c.deleteReqs = append(c.deleteReqs, req)
if err := ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
if attempt < len(c.deleteCallErrs) && c.deleteCallErrs[attempt] != nil {
return nil, c.deleteCallErrs[attempt]
}
if attempt < len(c.deleteRespErrs) && c.deleteRespErrs[attempt] != "" {
return &filer_pb.DeleteEntryResponse{Error: c.deleteRespErrs[attempt]}, nil
}
if c.deleteResp == nil {
return &filer_pb.DeleteEntryResponse{}, c.deleteErr
}
return c.deleteResp, c.deleteErr
}
func (c *deleteObjectEntryTestClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
c.lookupReq = req
if c.lookupResp == nil {
return &filer_pb.LookupDirectoryEntryResponse{}, c.lookupErr
}
return c.lookupResp, c.lookupErr
}
func (c *deleteObjectEntryTestClient) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
c.updateReq = req
return &filer_pb.UpdateEntryResponse{}, c.updateErr
}
func TestDeleteObjectEntryDemotesNonEmptyDirectoryMarker(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
Mime: "application/octet-stream",
Md5: []byte{1, 2, 3, 4},
FileSize: 4,
},
Content: []byte("test"),
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte("etag"),
s3_constants.ExtAmzOwnerKey: []byte("owner"),
s3_constants.AmzUserMetaPrefix + "Color": []byte("blue"),
s3_constants.AmzObjectTaggingPrefix + "k": []byte("v"),
"xattr-keep": []byte("keep-me"),
"x-seaweedfs-internal": []byte("keep-me-too"),
},
},
},
}
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
require.NoError(t, err)
require.NotNil(t, client.lookupReq)
require.NotNil(t, client.updateReq)
updated := client.updateReq.Entry
require.NotNil(t, updated)
assert.False(t, updated.IsDirectoryKeyObject())
assert.Equal(t, "", updated.Attributes.Mime)
assert.Empty(t, updated.Attributes.Md5)
assert.Zero(t, updated.Attributes.FileSize)
assert.Nil(t, updated.Content)
assert.Nil(t, updated.Chunks)
assert.Equal(t, map[string][]byte{
"xattr-keep": []byte("keep-me"),
"x-seaweedfs-internal": []byte("keep-me-too"),
}, updated.Extended)
}
// A prefix object is demoted the same way, and the mark has to go with the data:
// the path is a plain directory again, and no longer a key of its own.
func TestDeleteObjectEntryDemotesPrefixObject(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{
s3_constants.SeaweedFSPrefixObject: []byte("true"),
s3_constants.ExtETagKey: []byte("etag"),
},
},
},
}
require.NoError(t, deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false))
require.NotNil(t, client.updateReq)
updated := client.updateReq.Entry
require.NotNil(t, updated)
assert.False(t, updated.IsPrefixObject())
assert.False(t, updated.IsDirectoryKeyObject())
assert.Empty(t, updated.Extended)
}
func TestDeleteObjectEntryTreatsImplicitDirectoryAsSuccessfulNoop(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
},
},
}
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
require.NoError(t, err)
require.NotNil(t, client.lookupReq)
assert.Nil(t, client.updateReq)
}
func TestDeleteObjectEntryIgnoresConcurrentUpdateNotFound(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
Mime: "application/octet-stream",
},
},
},
updateErr: status.Error(codes.NotFound, "already removed"),
}
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
require.NoError(t, err)
require.NotNil(t, client.lookupReq)
require.NotNil(t, client.updateReq)
}
// The key is the client's and the filer echoes it in the message it sends back,
// so a key named after the marker must not turn a real failure into the demote
// no-op, which would answer a failed delete with a 204.
func TestDeleteObjectEntryIgnoresMarkerSpoofedByKey(t *testing.T) {
name := filer.MsgFailDelNonEmptyFolder
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: "delete file /buckets/test/" + name + ": filer store delete: disk full",
},
}
err := deleteObjectEntry(context.Background(), client, "/buckets/test", name, true, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "disk full")
assert.Nil(t, client.lookupReq)
assert.Nil(t, client.updateReq)
}
func TestDeleteObjectEntryPropagatesNonDirectoryDeleteErrors(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteErr: errors.New("boom"),
}
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "boom")
assert.Nil(t, client.lookupReq)
assert.Nil(t, client.updateReq)
}
// A delete is idempotent at the filer, so a reply the transport dropped is
// reissued rather than surfaced. Issue #7204 saw the surfaced version as a 500
// on the bucket delete, which boto3 resent and then answered NoSuchBucket;
// issue #7224 saw it as a per-key InternalError inside a 200, which no SDK
// retries, so the object silently stayed.
func TestDeleteUnversionedObjectReplaysADroppedReply(t *testing.T) {
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
client := &deleteObjectEntryTestClient{
deleteCallErrs: []error{status.Error(codes.Unavailable, "transport is closing"), nil},
}
err := s3a.deleteUnversionedObjectWithClient(testRetryCtx(10*time.Millisecond), client, "mybucket", "a/b/c.txt", false)
require.NoError(t, err)
assert.Equal(t, 2, client.deleteAttempts)
// the replay must ask for the same delete, not a differently shaped one
require.Len(t, client.deleteReqs, 2)
assert.Equal(t, client.deleteReqs[0].String(), client.deleteReqs[1].String())
}
// The filer formats the deleted path, and for a recursive delete the child it
// stopped on, into the message it sends back, so both are the client's text.
// Nothing here reads that text, and this pins it: the same failure under a key
// named after a transport condition is replayed exactly as often as under a
// plain one.
func TestDeleteUnversionedObjectReplayIgnoresTheObjectName(t *testing.T) {
attemptsFor := func(key string) int {
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
client := &deleteObjectEntryTestClient{
deleteRespErrs: []string{"delete file /buckets/b/" + key + ": permission denied", ""},
}
require.NoError(t, s3a.deleteUnversionedObjectWithClient(testRetryCtx(10*time.Millisecond), client, "b", key, false))
return client.deleteAttempts
}
plain := attemptsFor("plain.log")
for _, key := range []string{"transport.log", "unavailable.txt", "slowdown.csv", filer_pb.ErrNotFound.Error()} {
assert.Equal(t, plain, attemptsFor(key), "key %q must not steer the replay", key)
}
}
// A folder the filer refused because it still has children is an answer about
// the tree, not a hiccup: it is handed straight to the demote path instead of
// being replayed until the allowance runs out.
func TestDeleteUnversionedObjectDoesNotReplayANonEmptyFolder(t *testing.T) {
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/b/photos",
},
}
require.NoError(t, s3a.deleteUnversionedObjectWithClient(context.Background(), client, "b", "photos", false))
assert.Equal(t, 1, client.deleteAttempts)
assert.NotNil(t, client.lookupReq, "the demote must still run")
}
// A caller that has gone away is not worth replaying for.
func TestDeleteUnversionedObjectDoesNotReplayForACancelledCaller(t *testing.T) {
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
client := &deleteObjectEntryTestClient{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := s3a.deleteUnversionedObjectWithClient(ctx, client, "b", "k", false)
require.Error(t, err)
assert.Equal(t, 1, client.deleteAttempts)
}
// The client picks how many keys a multi-object delete carries, so the backoff
// is drawn from one allowance held by the request rather than one per key.
func TestDeleteUnversionedObjectDrawsFromTheRequestAllowance(t *testing.T) {
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
ctx := testRetryCtx(20 * time.Millisecond)
start := time.Now()
var attempts int
for i := 0; i < 50; i++ {
client := &deleteObjectEntryTestClient{deleteErr: status.Error(codes.Unavailable, "transport is closing")}
require.Error(t, s3a.deleteUnversionedObjectWithClient(ctx, client, "b", fmt.Sprintf("k%d", i), false))
attempts += client.deleteAttempts
}
assert.Less(t, time.Since(start), time.Second, "one allowance, not one per key")
assert.Greater(t, attempts, 50, "the allowance must still buy a replay")
}
// testRetryCtx caps the backoff so a test exercises the replay without waiting
// out the real per-request allowance.
func testRetryCtx(allowance time.Duration) context.Context {
return withFilerRetryBudget(context.Background(), allowance)
}
func TestIsRetryableFilerErr(t *testing.T) {
assert.False(t, isRetryableFilerErr(nil))
assert.True(t, isRetryableFilerErr(status.Error(codes.Unavailable, "transport is closing")))
assert.True(t, isRetryableFilerErr(fmt.Errorf("delete entry /b/k: %w", status.Error(codes.Unavailable, "x"))))
// the filer's own message carries no status, and a store error may well clear
assert.True(t, isRetryableFilerErr(filer.DeleteEntryError("delete file /b/k: filer store delete: disk full")))
assert.False(t, isRetryableFilerErr(filer.DeleteEntryError(filer.MsgFailDelNonEmptyFolder+": /b/photos")))
assert.False(t, isRetryableFilerErr(filer_pb.ErrNotFound))
assert.False(t, isRetryableFilerErr(status.Error(codes.NotFound, "gone")))
assert.False(t, isRetryableFilerErr(context.Canceled))
assert.False(t, isRetryableFilerErr(status.Error(codes.Canceled, "context canceled")))
assert.False(t, isRetryableFilerErr(status.Error(codes.DeadlineExceeded, "context deadline exceeded")))
// the message is never read, so the same code classifies the same way
// whatever the client named the key
assert.Equal(t,
isRetryableFilerErr(status.Error(codes.PermissionDenied, "denied for /b/plain.log")),
isRetryableFilerErr(status.Error(codes.PermissionDenied, "denied for /b/transport.log")))
}

View File

@@ -0,0 +1,297 @@
package s3api
import (
"context"
"fmt"
"io"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
grpc "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// listRetryStream replays a scripted sequence of entries and then, optionally,
// fails. failAfter < 0 means the stream runs to EOF.
type listRetryStream struct {
entries []*filer_pb.Entry
index int
failAfter int
failErr error
}
func (s *listRetryStream) Recv() (*filer_pb.ListEntriesResponse, error) {
if s.failErr != nil && s.failAfter >= 0 && s.index >= s.failAfter {
return nil, s.failErr
}
if s.index >= len(s.entries) {
return nil, io.EOF
}
entry := s.entries[s.index]
s.index++
return &filer_pb.ListEntriesResponse{Entry: entry}, nil
}
func (s *listRetryStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
func (s *listRetryStream) Trailer() metadata.MD { return metadata.MD{} }
func (s *listRetryStream) CloseSend() error { return nil }
func (s *listRetryStream) Context() context.Context { return context.Background() }
func (s *listRetryStream) SendMsg(any) error { return nil }
func (s *listRetryStream) RecvMsg(any) error { return nil }
// listRetryClient is a filer whose ListEntries fails in a scripted way for the
// first attempts and then behaves. callErrs is consumed one entry per attempt:
// a non-nil entry fails the ListEntries call itself (issue #7221), a nil entry
// lets the call through so recvErrs decides whether the stream breaks partway
// (issue #7235).
type listRetryClient struct {
filer_pb.SeaweedFilerClient
entries []*filer_pb.Entry
callErrs []error
recvErrs []error
recvAfter []int
attempts int
}
func (c *listRetryClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
attempt := c.attempts
c.attempts++
if attempt < len(c.callErrs) && c.callErrs[attempt] != nil {
return nil, c.callErrs[attempt]
}
stream := &listRetryStream{entries: c.entries, failAfter: -1}
if attempt < len(c.recvErrs) && c.recvErrs[attempt] != nil {
stream.failErr = c.recvErrs[attempt]
stream.failAfter = c.recvAfter[attempt]
}
return stream, nil
}
// listRetryAccessor hands the scripted client to filer_pb.List without any
// wrapping of its own, so the test sees the error exactly as it leaves
// DoSeaweedListWithSnapshot.
type listRetryAccessor struct {
client filer_pb.SeaweedFilerClient
}
func (a *listRetryAccessor) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(a.client)
}
func (a *listRetryAccessor) AdjustedUrl(*filer_pb.Location) string { return "" }
func (a *listRetryAccessor) GetDataCenter() string { return "" }
// listOnce mirrors (*S3ApiServer).list so the test drives the real
// filer_pb.List and DoSeaweedListWithSnapshot code path, including the fresh
// accumulator that makes a replay safe.
func listOnce(client filer_pb.FilerClient, parentDirectoryPath string) (entries []*filer_pb.Entry, isLast bool, err error) {
err = filer_pb.List(context.Background(), client, parentDirectoryPath, "", func(entry *filer_pb.Entry, isLastEntry bool) error {
entries = append(entries, entry)
if isLastEntry {
isLast = true
}
return nil
}, "", false, 100)
if len(entries) == 0 {
isLast = true
}
return
}
func testUploadEntries(names ...string) []*filer_pb.Entry {
entries := make([]*filer_pb.Entry, 0, len(names))
for _, name := range names {
entries = append(entries, &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}})
}
return entries
}
func entryNames(entries []*filer_pb.Entry) []string {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name)
}
return names
}
// Issue #7221: a transient failure of the ListEntries call itself.
func TestListWithRetryRecoversFromTransientListEntriesCall(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1", "upload-2", "upload-3"),
callErrs: []error{
status.Error(codes.Unavailable, "filer is restarting"),
status.Error(codes.Unavailable, "filer is restarting"),
nil,
},
}
accessor := &listRetryAccessor{client: client}
entries, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, "/buckets/b/.uploads")
})
require.NoError(t, err)
assert.Equal(t, []string{"upload-1", "upload-2", "upload-3"}, entryNames(entries))
assert.Equal(t, listRetryAttempts, client.attempts, "should have used every allowed attempt")
}
// Issue #7235: a transient failure on stream.Recv part way through the listing.
// The replay must return the full listing exactly once, with no entry from the
// aborted attempt carried over.
func TestListWithRetryRecoversFromTransientStreamRecv(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1", "upload-2", "upload-3"),
// break after two successful receives, so the first attempt has already
// handed "upload-1" to the callback when the stream dies
recvErrs: []error{status.Error(codes.Unavailable, "transport is closing"), nil},
recvAfter: []int{2, 0},
}
accessor := &listRetryAccessor{client: client}
entries, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, "/buckets/b/.uploads")
})
require.NoError(t, err)
assert.Equal(t, []string{"upload-1", "upload-2", "upload-3"}, entryNames(entries), "replay must not duplicate the entries of the aborted attempt")
assert.Equal(t, 2, client.attempts)
}
// The retry is bounded: a filer that stays down still fails the request, and
// the transient error reaches the caller.
func TestListWithRetryGivesUpAfterBoundedAttempts(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1"),
callErrs: []error{
status.Error(codes.Unavailable, "no filer available"),
status.Error(codes.Unavailable, "no filer available"),
status.Error(codes.Unavailable, "no filer available"),
nil, // would succeed on a fourth attempt, which must never happen
},
}
accessor := &listRetryAccessor{client: client}
_, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, "/buckets/b/.uploads")
})
require.Error(t, err)
assert.Contains(t, err.Error(), "Unavailable")
assert.Equal(t, listRetryAttempts, client.attempts)
}
// A non-transient error is a real failure and must propagate on the first
// attempt rather than being retried away.
func TestListWithRetryPropagatesNonTransientError(t *testing.T) {
for name, listErr := range map[string]error{
"permission denied": status.Error(codes.PermissionDenied, "not allowed"),
"invalid argument": status.Error(codes.InvalidArgument, "bad prefix"),
} {
t.Run(name, func(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1"),
callErrs: []error{listErr, nil},
}
accessor := &listRetryAccessor{client: client}
_, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, "/buckets/b/.uploads")
})
require.Error(t, err)
assert.Equal(t, 1, client.attempts, "a non-transient error must not be retried")
})
}
}
// Not-found is authoritative: it must not be retried, and it must still read as
// not-found afterwards so listMultipartUploads keeps answering with an empty
// list instead of a 500.
func TestListWithRetryPropagatesNotFound(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1"),
callErrs: []error{status.Error(codes.NotFound, "filer: no entry is found in filer store"), nil},
}
accessor := &listRetryAccessor{client: client}
_, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, "/buckets/b/.uploads")
})
require.Error(t, err)
assert.Equal(t, 1, client.attempts, "not-found is authoritative and must not be retried")
assert.True(t, isFilerNotFound(err), "not-found must survive the retry wrapper")
}
func TestIsRetryableListError(t *testing.T) {
assert.False(t, isRetryableListError(nil))
assert.True(t, isRetryableListError(status.Error(codes.Unavailable, "connection refused")))
assert.True(t, isRetryableListError(status.Error(codes.ResourceExhausted, "too many requests")))
// the ListEntries path loses the gRPC status to a %v wrap, so classification
// has to survive on the message alone
assert.True(t, isRetryableListError(fmt.Errorf("list /buckets/b/.uploads: %v", status.Error(codes.Unavailable, "filer is restarting"))))
// and this is the shape the S3 gateway actually sees once the failover in
// (*S3ApiServer).WithFilerClient has exhausted every filer
assert.True(t, isRetryableListError(fmt.Errorf("all filers failed, last error: %w",
fmt.Errorf("list /buckets/b/.uploads: %v", status.Error(codes.Unavailable, "filer is restarting")))))
assert.False(t, isRetryableListError(status.Error(codes.NotFound, "filer: no entry is found in filer store")))
assert.False(t, isRetryableListError(status.Error(codes.PermissionDenied, "not allowed")))
assert.False(t, isRetryableListError(context.Canceled))
}
// The bucket and the prefix are the client's to choose and they land inside the
// message DoSeaweedListWithSnapshot builds, so they must not decide whether the
// failure is retried.
func TestListWithRetryIgnoresUserControlledPath(t *testing.T) {
for name, listErr := range map[string]error{
"permission denied": status.Error(codes.PermissionDenied, "not allowed"),
"invalid argument": status.Error(codes.InvalidArgument, "bad prefix"),
} {
t.Run(name, func(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1"),
callErrs: []error{listErr, nil},
}
accessor := &listRetryAccessor{client: client}
_, _, err := listWithRetry("/buckets/transport/unavailable", func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, "/buckets/transport/unavailable")
})
require.Error(t, err)
assert.Equal(t, 1, client.attempts, "a bucket name must not make a non-transient error look transient")
})
}
}
// The inverse: a prefix carrying the not-found sentence must not suppress a
// retry the filer actually asked for.
func TestListWithRetryIgnoresSpoofedNotFoundPath(t *testing.T) {
client := &listRetryClient{
entries: testUploadEntries("upload-1"),
callErrs: []error{
status.Error(codes.Unavailable, "filer is restarting"),
nil,
},
}
accessor := &listRetryAccessor{client: client}
dir := "/buckets/b/filer: no entry is found in filer store"
entries, _, err := listWithRetry(dir, func() ([]*filer_pb.Entry, bool, error) {
return listOnce(accessor, dir)
})
require.NoError(t, err)
assert.Equal(t, []string{"upload-1"}, entryNames(entries))
assert.Equal(t, 2, client.attempts, "an object name must not suppress a retry")
}

View File

@@ -0,0 +1,106 @@
package s3api
import (
"context"
"strings"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
const (
S3TAG_PREFIX = s3_constants.AmzObjectTagging + "-"
)
func (s3a *S3ApiServer) getTags(parentDirectoryPath string, entryName string) (tags map[string]string, err error) {
err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDirectoryPath,
Name: entryName,
})
if err != nil {
return err
}
tags = make(map[string]string)
for k, v := range resp.Entry.Extended {
if strings.HasPrefix(k, S3TAG_PREFIX) {
tags[k[len(S3TAG_PREFIX):]] = string(v)
}
}
return nil
})
return
}
func (s3a *S3ApiServer) setTags(parentDirectoryPath string, entryName string, tags map[string]string) (err error) {
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDirectoryPath,
Name: entryName,
})
if err != nil {
return err
}
for k, _ := range resp.Entry.Extended {
if strings.HasPrefix(k, S3TAG_PREFIX) {
delete(resp.Entry.Extended, k)
}
}
if resp.Entry.Extended == nil {
resp.Entry.Extended = make(map[string][]byte)
}
for k, v := range tags {
resp.Entry.Extended[S3TAG_PREFIX+k] = []byte(v)
}
return filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
Directory: parentDirectoryPath,
Entry: resp.Entry,
IsFromOtherCluster: false,
Signatures: nil,
})
})
}
func (s3a *S3ApiServer) rmTags(parentDirectoryPath string, entryName string) (err error) {
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDirectoryPath,
Name: entryName,
})
if err != nil {
return err
}
hasDeletion := false
for k, _ := range resp.Entry.Extended {
if strings.HasPrefix(k, S3TAG_PREFIX) {
delete(resp.Entry.Extended, k)
hasDeletion = true
}
}
if !hasDeletion {
return nil
}
return filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
Directory: parentDirectoryPath,
Entry: resp.Entry,
IsFromOtherCluster: false,
Signatures: nil,
})
})
}

View File

@@ -0,0 +1,93 @@
package s3api
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/require"
)
// TestAuthorizeObjectDelete_AwsCanonicalPolicy: a policy granting s3:DeleteObject
// on <bucket>/* must allow per-key batch deletes. Pre-fix the bucket-level check
// built arn:aws:s3:::<bucket> and never matched the object-scoped policy.
func TestAuthorizeObjectDelete_AwsCanonicalPolicy(t *testing.T) {
const bucket = "test-bucket"
const policyName = "delete-test-bucket-objects"
policyDoc, err := json.Marshal(map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{
{
"Effect": "Allow",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::" + bucket + "/*",
},
},
})
require.NoError(t, err)
iam := &IdentityAccessManagement{
isAuthEnabled: true,
}
require.NoError(t, iam.PutPolicy(policyName, string(policyDoc)))
identity := &Identity{
Name: "alice",
Account: &AccountAdmin,
PolicyNames: []string{policyName},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
r := httptest.NewRequest("POST", "/"+bucket+"?delete", nil)
require.Equal(t, s3err.ErrNone,
iam.AuthorizeObjectDelete(r, identity, bucket, "objects/a.txt", ""),
"s3:DeleteObject on arn:aws:s3:::%s/* must allow deleting %s/objects/a.txt", bucket, bucket)
require.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeObjectDelete(r, identity, "other-bucket", "objects/a.txt", ""),
"keys outside the granted bucket must be denied")
}
// TestAuthorizeObjectDelete_PrefixScopedPolicy: a prefix-scoped policy must allow
// batch deletes under the prefix and deny keys outside it, per-key.
func TestAuthorizeObjectDelete_PrefixScopedPolicy(t *testing.T) {
const bucket = "test-bucket"
const policyName = "delete-prefix-only"
policyDoc, err := json.Marshal(map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{
{
"Effect": "Allow",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::" + bucket + "/safe/*",
},
},
})
require.NoError(t, err)
iam := &IdentityAccessManagement{
isAuthEnabled: true,
}
require.NoError(t, iam.PutPolicy(policyName, string(policyDoc)))
identity := &Identity{
Name: "alice",
Account: &AccountAdmin,
PolicyNames: []string{policyName},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
r := httptest.NewRequest("POST", "/"+bucket+"?delete", nil)
require.Equal(t, s3err.ErrNone,
iam.AuthorizeObjectDelete(r, identity, bucket, "safe/inside.txt", ""),
"key under granted prefix must be allowed")
require.Equal(t, s3err.ErrAccessDenied,
iam.AuthorizeObjectDelete(r, identity, bucket, "danger/outside.txt", ""),
"key outside the granted prefix must be denied per-key, not at the batch level")
}

View File

@@ -0,0 +1,279 @@
package s3api
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/stretchr/testify/assert"
)
func TestLoadIAMManagerFromConfig_Defaults(t *testing.T) {
// Create a temporary config file with minimal content (just policy)
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_config.json")
configContent := `{
"sts": {
"providers": []
},
"policy": {
"storeType": "memory",
"defaultEffect": "Allow"
}
}`
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
// dummy filer address provider
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "default-secure-signing-key" }
// Load the manager
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestLoadIAMManagerFromConfig_Overrides(t *testing.T) {
// Create a temporary config file with EXPLICIT values
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_config_explicit.json")
configContent := `{
"sts": {
"tokenDuration": "2h",
"maxSessionLength": "24h",
"issuer": "custom-issuer",
"signingKey": "ZXhwbGljaXQtc2lnbmluZy1rZXktMTIzNDU="
},
"policy": {
"storeType": "memory",
"defaultEffect": "Allow"
}
}`
// Base64 encoded "explicit-signing-key-12345" is "ZXhwbGljaXQtc2lnbmluZy1rZXktMTIzNDU="
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "default-secure-signing-key" }
// Load
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestLoadIAMManagerFromConfig_PartialDefaults(t *testing.T) {
// Test that partial configs (e.g. providing SigningKey but not Duration) work
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_config_partial.json")
// Signing key provided in JSON, others missing
configContent := `{
"sts": {
"signingKey": "anNvbi1wcm92aWRlZC1rZXktMTIzNDU="
},
"policy": {
"storeType": "memory",
"defaultEffect": "Allow"
}
}`
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
filerProvider := func() string { return "localhost:8888" }
// Default signing key provided but should be IGNORED because JSON has one
defaultSigningKeyProvider := func() string { return "server-default-key-should-be-ignored" }
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestLoadIAMManagerFromConfig_ExplicitEmptyKey(t *testing.T) {
// Test that if JSON has empty signing key string, it still falls back
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_config_empty_key.json")
// Signing key explicitly empty
configContent := `{
"sts": {
"signingKey": ""
},
"policy": {
"storeType": "memory",
"defaultEffect": "Allow"
}
}`
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "fallback-key-should-be-used" }
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestLoadIAMManagerFromConfig_MissingKeyError(t *testing.T) {
// Test that if BOTH keys are empty, it fails with a clear error
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_config_all_empty.json")
// Signing key explicitly empty in JSON
configContent := `{
"sts": {
"signingKey": ""
},
"policy": {
"storeType": "memory",
"defaultEffect": "Allow"
}
}`
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "" } // Empty default too
// Ensure no SSE-S3 key interferes (global state in tests is tricky, but let's assume clean state or no mock)
// Ideally we would mock GetSSES3KeyManager().GetMasterKey() but it's a global singleton.
// For this unit test, if the global key manager has no key, it should fail.
_, err = loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
// Should return a clear error
assert.Error(t, err)
assert.Contains(t, err.Error(), "no signing key found for STS service")
}
func TestLoadIAMManagerFromConfig_ExplicitFileDefaultsToDeny(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_config_implicit_policy_defaults.json")
// Explicit config file with no policy.defaultEffect should default to Deny.
configContent := `{
"sts": {
"issuer": "explicit-config"
}
}`
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "fallback-key-for-explicit-config" }
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
assert.False(t, manager.DefaultAllow())
}
func TestLoadIAMManagerFromConfig_NoFileDefaultsToAllow(t *testing.T) {
// No explicit IAM file should preserve zero-config startup behavior.
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "fallback-key-for-zero-config" }
manager, err := loadIAMManagerFromConfig("", filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
assert.True(t, manager.DefaultAllow())
}
func TestLoadIAMManagerFromConfig_ExplicitFileEnforcesUserScopedPolicy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "iam_regression_8366.json")
// Regression coverage for #8366:
// with explicit IAM config and omitted policy.defaultEffect, unrestricted bucket creation
// must NOT be allowed.
configContent := `{
"sts": {
"issuer": "seaweedfs-sts"
},
"policies": [
{
"name": "S3UserPolicy",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
"arn:aws:s3:::user-${jwt:preferred_username}",
"arn:aws:s3:::user-${jwt:preferred_username}/*"
]
}
]
}
}
],
"roles": [
{
"roleName": "S3UserRole",
"roleArn": "arn:aws:iam::role/S3UserRole",
"attachedPolicies": ["S3UserPolicy"],
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Federated": "*"},
"Action": ["sts:AssumeRoleWithWebIdentity"]
}
]
}
}
]
}`
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
filerProvider := func() string { return "localhost:8888" }
defaultSigningKeyProvider := func() string { return "fallback-key-for-regression-8366" }
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
assert.NoError(t, err)
assert.NotNil(t, manager)
assert.False(t, manager.DefaultAllow())
ctx := context.Background()
principal := "arn:aws:sts::000000000000:assumed-role/S3UserRole/alice-session"
reqCtx := map[string]interface{}{"jwt:preferred_username": "alice"}
allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
Principal: principal,
Action: "s3:CreateBucket",
Resource: "arn:aws:s3:::arbitrary-bucket",
RequestContext: reqCtx,
})
assert.NoError(t, err)
assert.False(t, allowed, "arbitrary bucket creation should be denied")
allowed, err = manager.IsActionAllowed(ctx, &integration.ActionRequest{
Principal: principal,
Action: "s3:CreateBucket",
Resource: "arn:aws:s3:::user-alice",
RequestContext: reqCtx,
})
assert.NoError(t, err)
assert.True(t, allowed, "user-scoped bucket creation should be allowed")
}

View File

@@ -0,0 +1,114 @@
package s3api
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/require"
)
// ListObjects requests carry their key scope in the ?prefix= query parameter,
// which authRequestWithAuthType promotes into object so the legacy CanDo path
// can honor prefix-scoped Action strings. That promoted object must not reach
// the policy resource ARN: s3:ListBucket is a bucket-level action, so the
// resource stays arn:aws:s3:::bucket and any prefix scoping is expressed via
// the s3:prefix Condition.
func TestEvaluateIAMPolicies_ListBucketWithPrefix(t *testing.T) {
const bucket = "test-bucket"
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("list-bucket", mustPolicy(t, map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::" + bucket,
}},
})))
identity := &Identity{
Name: "alice",
Account: &AccountAdmin,
PolicyNames: []string{"list-bucket"},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
// authRequestWithAuthType promotes prefix into object before reaching the
// IAM evaluator; pass the post-promotion value to mirror that flow.
withPrefix := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=foo/", nil)
require.True(t, iam.evaluateIAMPolicies(withPrefix, identity, s3_constants.ACTION_LIST, bucket, "foo/"),
"s3:ListBucket on the bucket ARN must allow listing with a prefix")
noPrefix := httptest.NewRequest("GET", "/"+bucket+"?list-type=2", nil)
require.True(t, iam.evaluateIAMPolicies(noPrefix, identity, s3_constants.ACTION_LIST, bucket, ""),
"s3:ListBucket on the bucket ARN must allow listing without a prefix")
}
// Prefix scoping still works once it moves to the Condition: a policy that
// grants s3:ListBucket on the bucket only under an s3:prefix StringLike must
// allow a matching prefix and deny a non-matching one.
func TestEvaluateIAMPolicies_ListBucketPrefixCondition(t *testing.T) {
const bucket = "test-bucket"
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("list-scoped", mustPolicy(t, map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::" + bucket,
"Condition": map[string]any{"StringLike": map[string]any{"s3:prefix": "warehouse/*"}},
}},
})))
identity := &Identity{
Name: "bob",
Account: &AccountAdmin,
PolicyNames: []string{"list-scoped"},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
matching := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=warehouse/data", nil)
require.True(t, iam.evaluateIAMPolicies(matching, identity, s3_constants.ACTION_LIST, bucket, "warehouse/data"),
"prefix matching the s3:prefix condition must be allowed")
nonMatching := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=secrets/", nil)
require.False(t, iam.evaluateIAMPolicies(nonMatching, identity, s3_constants.ACTION_LIST, bucket, "secrets/"),
"prefix outside the s3:prefix condition must be denied")
}
// Listing variants keep their specific action even when a prefix is promoted
// into object: ?versions resolves to s3:ListBucketVersions, not s3:ListBucket.
func TestEvaluateIAMPolicies_ListBucketVersionsWithPrefix(t *testing.T) {
const bucket = "test-bucket"
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("list-versions", mustPolicy(t, map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": "s3:ListBucketVersions",
"Resource": "arn:aws:s3:::" + bucket,
}},
})))
identity := &Identity{
Name: "carol",
Account: &AccountAdmin,
PolicyNames: []string{"list-versions"},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
r := httptest.NewRequest("GET", "/"+bucket+"?versions&prefix=foo/", nil)
require.True(t, iam.evaluateIAMPolicies(r, identity, s3_constants.ACTION_LIST, bucket, "foo/"),
"s3:ListBucketVersions must still resolve when listing with a prefix")
}
func mustPolicy(t *testing.T, doc map[string]any) string {
t.Helper()
b, err := json.Marshal(doc)
require.NoError(t, err)
return string(b)
}

View File

@@ -0,0 +1,76 @@
package s3api
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/stretchr/testify/assert"
)
// resetMemoryStore resets the shared in-memory credential store so that tests
// that rely on an empty store are not polluted by earlier tests.
func resetMemoryStore() {
for _, store := range credential.Stores {
if store.GetName() == credential.StoreTypeMemory {
if resettable, ok := store.(interface{ Reset() }); ok {
resettable.Reset()
}
}
}
}
func TestLoadIAMManagerWithNoConfig(t *testing.T) {
// Verify that IAM can be initialized without any config
option := &S3ApiServerOption{
Config: "",
}
iamManager := NewIdentityAccessManagementWithStore(option, nil, "memory")
assert.NotNil(t, iamManager)
// Internal state might be hard to access directly, but successful init implies defaults worked.
}
func TestLoadIAMManagerFromConfig_EmptyConfigWithFallbackKey(t *testing.T) {
// Reset the shared memory store to avoid state leaking from other tests.
resetMemoryStore()
// Initialize IAM with empty config — no anonymous identity is configured,
// so LookupAnonymous should return not-found.
option := &S3ApiServerOption{
Config: "",
IamConfig: "",
EnableIam: true,
}
iamManager := NewIdentityAccessManagementWithStore(option, nil, "memory")
_, found := iamManager.LookupAnonymous()
assert.False(t, found, "Anonymous identity should not be found when not explicitly configured")
}
// TestSetIAMIntegrationKeepsAuthDisabledWithoutConfig is a regression test for
// issue #9557. The `weed mini` defaults (and the bare `docker run seaweedfs`
// image) start with EnableIam=true but no IAM config file and no identities.
// The advanced-IAM init path used to also flip isAuthEnabled to true via
// SetIAMIntegration, which then rejected every anonymous request as
// AccessDenied — breaking out-of-the-box S3 access. Setting an integration
// must not, on its own, enable auth enforcement; explicit configs use
// EnableAuthEnforcement to opt in.
func TestSetIAMIntegrationKeepsAuthDisabledWithoutConfig(t *testing.T) {
resetMemoryStore()
option := &S3ApiServerOption{
EnableIam: true,
}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
// Simulate an integration object being plugged in (the constructor in
// s3api_server.go does this when EnableIam=true, even with no config file).
// We only care that auth stays off — the integration value itself is opaque.
iam.SetIAMIntegration(&S3IAMIntegration{})
assert.False(t, iam.isEnabled(), "Auth must stay disabled when no identities and no IamConfig are configured")
// And EnableAuthEnforcement does flip it on — this is what the startup
// path runs when the operator explicitly passes -s3.iam.config.
iam.EnableAuthEnforcement()
assert.True(t, iam.isEnabled(), "EnableAuthEnforcement must turn auth on")
}

View File

@@ -0,0 +1,254 @@
package iceberg
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"strings"
"time"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
const requirementAssertCreate = "assert-create"
// sleepBeforeCommitRetry backs off between commit attempts, jittered so that
// writers that collided do not line up again on the next try.
func sleepBeforeCommitRetry(attempt int) {
jitter := time.Duration(rand.Int64N(int64(25 * time.Millisecond)))
time.Sleep(time.Duration(50*attempt)*time.Millisecond + jitter)
}
// stageCommitMetadata writes the metadata file a commit will point the catalog
// at, returning the name and location it landed on. The exclusive create keeps
// one writer from overwriting another's file; when the name is already taken
// the metadata goes to a unique one instead of failing, because the file there
// may be an orphan left by an interrupted commit, and refusing would wedge the
// table until an orphan sweep ran. The catalog pointer, not the file name,
// decides which commit won. This mirrors how the maintenance worker stages its
// own metadata.
func (s *Server) stageCommitMetadata(ctx context.Context, metadataBucket, metadataPath, location, metadataFileName string, metadataBytes []byte) (string, string, error) {
err := s.saveMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName, metadataBytes, true)
if errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
// v{N}-{uuid}, a form both the catalog and the maintenance worker
// still read the version from.
metadataFileName = fmt.Sprintf("%s-%s.metadata.json", strings.TrimSuffix(metadataFileName, ".metadata.json"), uuid.NewString())
glog.V(1).Infof("Iceberg: metadata file already staged for %s, using %s", location, metadataFileName)
err = s.saveMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName, metadataBytes, true)
}
if err != nil {
return "", "", err
}
return metadataFileName, fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName), nil
}
type icebergRequestError struct {
status int
errType string
message string
}
type createOnCommitInput struct {
bucketARN string
markerBucket string
namespace []string
tableName string
identityName string
location string
tableUUID uuid.UUID
baseMetadata table.Metadata
baseMetadataLoc string
baseMetadataVer int
updates table.Updates
statisticsUpdates []statisticsUpdate
}
func isS3TablesConflict(err error) bool {
if err == nil {
return false
}
if errors.Is(err, s3tables.ErrVersionTokenMismatch) {
return true
}
var tableErr *s3tables.S3TablesError
return errors.As(err, &tableErr) && tableErr.Type == s3tables.ErrCodeConflict
}
func isS3TablesNotFound(err error) bool {
if err == nil {
return false
}
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return true
}
var tableErr *s3tables.S3TablesError
return errors.As(err, &tableErr) &&
(tableErr.Type == s3tables.ErrCodeNoSuchTable || tableErr.Type == s3tables.ErrCodeNoSuchNamespace || strings.Contains(strings.ToLower(tableErr.Message), "not found"))
}
func hasAssertCreateRequirement(requirements table.Requirements) bool {
for _, requirement := range requirements {
if requirement.GetType() == requirementAssertCreate {
return true
}
}
return false
}
func isS3TablesAlreadyExists(err error) bool {
if err == nil {
return false
}
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
return true
}
var tableErr *s3tables.S3TablesError
return errors.As(err, &tableErr) &&
(tableErr.Type == s3tables.ErrCodeTableAlreadyExists || tableErr.Type == s3tables.ErrCodeNamespaceAlreadyExists || strings.Contains(strings.ToLower(tableErr.Message), "already exists"))
}
func (s *Server) finalizeCreateOnCommit(ctx context.Context, input createOnCommitInput) (*CommitTableResponse, *icebergRequestError) {
builder, err := table.MetadataBuilderFromBase(input.baseMetadata, input.baseMetadataLoc)
if err != nil {
return nil, &icebergRequestError{
status: http.StatusInternalServerError,
errType: "InternalServerError",
message: "Failed to create metadata builder: " + err.Error(),
}
}
for _, update := range input.updates {
if err := update.Apply(builder); err != nil {
return nil, &icebergRequestError{
status: http.StatusBadRequest,
errType: "BadRequestException",
message: "Failed to apply update: " + err.Error(),
}
}
}
newMetadata, err := builder.Build()
if err != nil {
return nil, &icebergRequestError{
status: http.StatusBadRequest,
errType: "BadRequestException",
message: "Failed to build new metadata: " + err.Error(),
}
}
metadataVersion := input.baseMetadataVer + 1
if metadataVersion <= 0 {
metadataVersion = 1
}
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(input.location, "/"), metadataFileName)
metadataBytes, err := json.Marshal(newMetadata)
if err != nil {
return nil, &icebergRequestError{
status: http.StatusInternalServerError,
errType: "InternalServerError",
message: "Failed to serialize metadata: " + err.Error(),
}
}
metadataBytes, err = applyStatisticsUpdates(metadataBytes, input.statisticsUpdates)
if err != nil {
return nil, &icebergRequestError{
status: http.StatusBadRequest,
errType: "BadRequestException",
message: "Failed to apply statistics updates: " + err.Error(),
}
}
metadataBytes = refreshDefaultNameMapping(metadataBytes, newMetadata)
// Same spec-compliance fixup we apply on create-table; ensures
// v{N}.metadata.json files written through this create-on-commit path are
// also readable by strict Iceberg clients reading directly from S3.
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
newMetadata, err = table.ParseMetadataBytes(metadataBytes)
if err != nil {
return nil, &icebergRequestError{
status: http.StatusInternalServerError,
errType: "InternalServerError",
message: "Failed to parse committed metadata: " + err.Error(),
}
}
metadataBucket, metadataPath, err := parseS3Location(input.location)
if err != nil {
return nil, &icebergRequestError{
status: http.StatusInternalServerError,
errType: "InternalServerError",
message: "Invalid table location: " + err.Error(),
}
}
if err := confineMetadataLocation(metadataBucket, metadataPath, input.markerBucket); err != nil {
return nil, &icebergRequestError{
status: http.StatusBadRequest,
errType: "BadRequestException",
message: err.Error(),
}
}
if err := s.saveMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
return nil, &icebergRequestError{
status: http.StatusInternalServerError,
errType: "InternalServerError",
message: "Failed to save metadata file: " + err.Error(),
}
}
createReq := &s3tables.CreateTableRequest{
TableBucketARN: input.bucketARN,
Namespace: input.namespace,
Name: input.tableName,
Format: "ICEBERG",
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
TableUUID: input.tableUUID.String(),
},
FullMetadata: metadataBytes,
},
MetadataVersion: metadataVersion,
MetadataLocation: newMetadataLocation,
}
createErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(ctx, mgrClient, "CreateTable", createReq, nil, input.identityName)
})
if createErr != nil {
if cleanupErr := s.deleteMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after create-on-commit failure: %v", newMetadataLocation, cleanupErr)
}
if isS3TablesConflict(createErr) || isS3TablesAlreadyExists(createErr) {
return nil, &icebergRequestError{
status: http.StatusConflict,
errType: "CommitFailedException",
message: "Table was created concurrently",
}
}
glog.Errorf("Iceberg: CommitTable CreateTable error: %v", createErr)
return nil, &icebergRequestError{
status: http.StatusInternalServerError,
errType: "InternalServerError",
message: "Failed to commit table creation: " + createErr.Error(),
}
}
markerBucket := input.markerBucket
if markerBucket == "" {
markerBucket = metadataBucket
}
if markerErr := s.deleteStageCreateMarkers(ctx, markerBucket, input.namespace, input.tableName); markerErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup stage-create markers for %s.%s after finalize: %v", flattenNamespacePath(input.namespace), input.tableName, markerErr)
}
return &CommitTableResponse{
MetadataLocation: newMetadataLocation,
Metadata: newMetadata,
}, nil
}

View File

@@ -0,0 +1,173 @@
package iceberg
import (
"encoding/json"
"errors"
"github.com/apache/iceberg-go/table"
)
type statisticsUpdate struct {
set *table.StatisticsFile
remove *int64
}
var ErrIncompleteSetStatistics = errors.New("set-statistics requires snapshot-id, statistics-path, file-size-in-bytes, and file-footer-size-in-bytes")
type commitAction struct {
Action string `json:"action"`
}
type setStatisticsUpdate struct {
Action string `json:"action"`
SnapshotID *int64 `json:"snapshot-id,omitempty"`
StatisticsPath string `json:"statistics-path,omitempty"`
FileSizeInBytes *int64 `json:"file-size-in-bytes,omitempty"`
FileFooterSizeInBytes *int64 `json:"file-footer-size-in-bytes,omitempty"`
KeyMetadata *string `json:"key-metadata,omitempty"`
BlobMetadata []table.BlobMetadata `json:"blob-metadata,omitempty"`
Statistics *table.StatisticsFile `json:"statistics,omitempty"`
}
func (u *setStatisticsUpdate) asStatisticsFile() (*table.StatisticsFile, error) {
if u.Statistics != nil {
if u.Statistics.BlobMetadata == nil {
u.Statistics.BlobMetadata = []table.BlobMetadata{}
}
return u.Statistics, nil
}
if u.SnapshotID == nil || u.StatisticsPath == "" || u.FileSizeInBytes == nil || u.FileFooterSizeInBytes == nil {
return nil, ErrIncompleteSetStatistics
}
stats := &table.StatisticsFile{
SnapshotID: *u.SnapshotID,
StatisticsPath: u.StatisticsPath,
FileSizeInBytes: *u.FileSizeInBytes,
FileFooterSizeInBytes: *u.FileFooterSizeInBytes,
KeyMetadata: u.KeyMetadata,
BlobMetadata: u.BlobMetadata,
}
if stats.BlobMetadata == nil {
stats.BlobMetadata = []table.BlobMetadata{}
}
return stats, nil
}
type removeStatisticsUpdate struct {
Action string `json:"action"`
SnapshotID int64 `json:"snapshot-id"`
}
func parseCommitUpdates(rawUpdates []json.RawMessage) (table.Updates, []statisticsUpdate, error) {
filtered := make([]json.RawMessage, 0, len(rawUpdates))
statisticsUpdates := make([]statisticsUpdate, 0)
for _, raw := range rawUpdates {
var action commitAction
if err := json.Unmarshal(raw, &action); err != nil {
return nil, nil, err
}
switch action.Action {
case "set-statistics":
var setUpdate setStatisticsUpdate
if err := json.Unmarshal(raw, &setUpdate); err != nil {
return nil, nil, err
}
stats, err := setUpdate.asStatisticsFile()
if err != nil {
return nil, nil, err
}
statisticsUpdates = append(statisticsUpdates, statisticsUpdate{set: stats})
case "remove-statistics":
var removeUpdate removeStatisticsUpdate
if err := json.Unmarshal(raw, &removeUpdate); err != nil {
return nil, nil, err
}
snapshotID := removeUpdate.SnapshotID
statisticsUpdates = append(statisticsUpdates, statisticsUpdate{remove: &snapshotID})
default:
filtered = append(filtered, raw)
}
}
if len(filtered) == 0 {
return nil, statisticsUpdates, nil
}
data, err := json.Marshal(filtered)
if err != nil {
return nil, nil, err
}
var updates table.Updates
if err := json.Unmarshal(data, &updates); err != nil {
return nil, nil, err
}
return updates, statisticsUpdates, nil
}
func applyStatisticsUpdates(metadataBytes []byte, updates []statisticsUpdate) ([]byte, error) {
if len(updates) == 0 {
return metadataBytes, nil
}
var metadata map[string]json.RawMessage
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, err
}
var statistics []table.StatisticsFile
if rawStatistics, ok := metadata["statistics"]; ok && len(rawStatistics) > 0 {
if err := json.Unmarshal(rawStatistics, &statistics); err != nil {
return nil, err
}
}
statisticsBySnapshot := make(map[int64]table.StatisticsFile, len(statistics))
orderedSnapshotIDs := make([]int64, 0, len(statistics))
inOrder := make(map[int64]bool, len(statistics))
for _, stat := range statistics {
statisticsBySnapshot[stat.SnapshotID] = stat
if !inOrder[stat.SnapshotID] {
orderedSnapshotIDs = append(orderedSnapshotIDs, stat.SnapshotID)
inOrder[stat.SnapshotID] = true
}
}
for _, update := range updates {
if update.set != nil {
statisticsBySnapshot[update.set.SnapshotID] = *update.set
if !inOrder[update.set.SnapshotID] {
orderedSnapshotIDs = append(orderedSnapshotIDs, update.set.SnapshotID)
inOrder[update.set.SnapshotID] = true
}
continue
}
if update.remove != nil {
delete(statisticsBySnapshot, *update.remove)
}
}
statistics = make([]table.StatisticsFile, 0, len(statisticsBySnapshot))
for _, snapshotID := range orderedSnapshotIDs {
stat, ok := statisticsBySnapshot[snapshotID]
if !ok {
continue
}
statistics = append(statistics, stat)
}
if len(statistics) == 0 {
delete(metadata, "statistics")
} else {
data, err := json.Marshal(statistics)
if err != nil {
return nil, err
}
metadata["statistics"] = data
}
return json.Marshal(metadata)
}

View File

@@ -0,0 +1,404 @@
package iceberg
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"strings"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// handleUpdateTable commits updates to a table.
// Implements the Iceberg REST Catalog commit protocol.
func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Parse commit request and keep statistics updates separate because iceberg-go v0.4.0
// does not decode set/remove-statistics update actions yet.
var raw struct {
Identifier *TableIdentifier `json:"identifier,omitempty"`
Requirements json.RawMessage `json:"requirements"`
Updates []json.RawMessage `json:"updates"`
}
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
return
}
var req CommitTableRequest
req.Identifier = raw.Identifier
var statisticsUpdates []statisticsUpdate
if len(raw.Requirements) > 0 {
normalized, err := normalizeRequirements(raw.Requirements)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
return
}
if err := json.Unmarshal(normalized, &req.Requirements); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
return
}
}
if len(raw.Updates) > 0 {
var err error
req.Updates, statisticsUpdates, err = parseCommitUpdates(raw.Updates)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid updates: "+err.Error())
return
}
}
// Manifest repair runs once, as soon as the table location is known; on
// commit retries the updates already reference the repaired files. Repair
// is best effort end to end: the originals parsed already, so a repair
// that fails to re-parse is discarded rather than failing the commit.
manifestsRepaired := false
repairManifests := func(location string) {
if manifestsRepaired {
return
}
manifestsRepaired = true
repaired, changed := s.repairAddSnapshotManifests(r.Context(), location, raw.Updates)
if !changed {
return
}
repairedUpdates, repairedStatistics, err := parseCommitUpdates(repaired)
if err != nil {
glog.Warningf("Iceberg: repaired updates failed to parse, keeping originals: %v", err)
return
}
raw.Updates = repaired
req.Updates = repairedUpdates
statisticsUpdates = repairedStatistics
}
maxCommitAttempts := 3
generatedLegacyUUID := uuid.New()
stageCreateEnabled := isStageCreateEnabled()
for attempt := 1; attempt <= maxCommitAttempts; attempt++ {
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
if isS3TablesNotFound(err) {
location := fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
tableUUID := generatedLegacyUUID
baseMetadataVersion := 0
baseMetadataLocation := ""
var baseMetadata table.Metadata
var latestMarker *stageCreateMarker
if stageCreateEnabled {
var markerErr error
latestMarker, markerErr = s.loadLatestStageCreateMarker(r.Context(), bucketName, namespace, tableName)
if markerErr != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to load stage-create marker: "+markerErr.Error())
return
}
}
if latestMarker != nil {
if latestMarker.Location != "" {
location = strings.TrimSuffix(latestMarker.Location, "/")
}
if latestMarker.TableUUID != "" {
if parsedUUID, parseErr := uuid.Parse(latestMarker.TableUUID); parseErr == nil {
tableUUID = parsedUUID
}
}
stagedMetadataLocation := latestMarker.StagedMetadataLocation
if stagedMetadataLocation == "" {
stagedMetadataLocation = fmt.Sprintf("%s/metadata/v1.metadata.json", strings.TrimSuffix(location, "/"))
}
stagedLocation := tableLocationFromMetadataLocation(stagedMetadataLocation)
stagedFileName := path.Base(stagedMetadataLocation)
stagedBucket, stagedPath, parseLocationErr := parseS3Location(stagedLocation)
if parseLocationErr != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid staged metadata location: "+parseLocationErr.Error())
return
}
if err := confineMetadataLocation(stagedBucket, stagedPath, bucketName); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
stagedMetadataBytes, loadErr := s.loadMetadataFile(r.Context(), stagedBucket, stagedPath, stagedFileName)
if loadErr != nil {
if !errors.Is(loadErr, filer_pb.ErrNotFound) {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to load staged metadata: "+loadErr.Error())
return
}
} else if len(stagedMetadataBytes) > 0 {
stagedMetadata, parseErr := table.ParseMetadataBytes(stagedMetadataBytes)
if parseErr != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse staged metadata: "+parseErr.Error())
return
}
// Staged metadata is only a template for table creation; commit starts from version 1.
baseMetadata = stagedMetadata
baseMetadataLocation = ""
baseMetadataVersion = 0
if stagedMetadata.TableUUID() != uuid.Nil {
tableUUID = stagedMetadata.TableUUID()
}
}
}
hasAssertCreate := hasAssertCreateRequirement(req.Requirements)
hasStagedTemplate := baseMetadata != nil
if !(stageCreateEnabled && (hasAssertCreate || hasStagedTemplate)) {
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName))
return
}
// From here the commit creates the table, writing its metadata
// file before the create that authorizes it.
if authErr := s.authorizeCreateTable(r.Context(), bucketARN, namespace, tableName, identityName); authErr != nil {
writeManagerError(w, authErr)
return
}
for _, requirement := range req.Requirements {
validateAgainst := table.Metadata(nil)
if hasStagedTemplate && requirement.GetType() != requirementAssertCreate {
validateAgainst = baseMetadata
}
if requirementErr := requirement.Validate(validateAgainst); requirementErr != nil {
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+requirementErr.Error())
return
}
}
if baseMetadata == nil {
var buildErr error
if baseMetadata, buildErr = newTableMetadata(tableUUID, location, nil, nil, nil, nil); buildErr != nil {
glog.Errorf("Iceberg: CommitTable placeholder metadata for %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata")
return
}
}
createBucket, createPath, createLocErr := parseS3Location(location)
if createLocErr != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+createLocErr.Error())
return
}
if err := confineMetadataLocation(createBucket, createPath, bucketName); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
repairManifests(location)
result, reqErr := s.finalizeCreateOnCommit(r.Context(), createOnCommitInput{
bucketARN: bucketARN,
markerBucket: bucketName,
namespace: namespace,
tableName: tableName,
identityName: identityName,
location: location,
tableUUID: tableUUID,
baseMetadata: baseMetadata,
baseMetadataLoc: baseMetadataLocation,
baseMetadataVer: baseMetadataVersion,
updates: req.Updates,
statisticsUpdates: statisticsUpdates,
})
if reqErr != nil {
writeError(w, reqErr.status, reqErr.errType, reqErr.message)
return
}
writeJSON(w, http.StatusOK, result)
return
}
glog.V(1).Infof("Iceberg: CommitTable GetTable error: %v", err)
writeManagerError(w, err)
return
}
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
}
locBucket, locPath, locErr := parseS3Location(location)
if locErr != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+locErr.Error())
return
}
if err := confineMetadataLocation(locBucket, locPath, bucketName); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
tableUUID := uuid.Nil
if getResp.Metadata != nil && getResp.Metadata.Iceberg != nil && getResp.Metadata.Iceberg.TableUUID != "" {
if parsed, parseErr := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); parseErr == nil {
tableUUID = parsed
}
}
if tableUUID == uuid.Nil {
tableUUID = generatedLegacyUUID
}
var currentMetadata table.Metadata
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
glog.Errorf("Iceberg: Failed to parse current metadata for %s: %v", tableName, err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata")
return
}
} else {
currentMetadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
if err != nil {
glog.Errorf("Iceberg: CommitTable placeholder metadata for %s: %v", tableName, err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata")
return
}
}
for _, requirement := range req.Requirements {
if err := requirement.Validate(currentMetadata); err != nil {
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+err.Error())
return
}
}
repairManifests(location)
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: "+err.Error())
return
}
for _, update := range req.Updates {
if err := update.Apply(builder); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply update: "+err.Error())
return
}
}
newMetadata, err := builder.Build()
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: "+err.Error())
return
}
metadataVersion := getResp.MetadataVersion + 1
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
metadataBytes, err := json.Marshal(newMetadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: "+err.Error())
return
}
// iceberg-go does not currently support set/remove-statistics updates in MetadataBuilder.
// Patch the encoded metadata JSON and parse it back to keep the response object consistent.
metadataBytes, err = applyStatisticsUpdates(metadataBytes, statisticsUpdates)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: "+err.Error())
return
}
metadataBytes = refreshDefaultNameMapping(metadataBytes, newMetadata)
// Same spec-compliance fixup we apply on create-table; ensures
// v{N}.metadata.json files written during commit are also readable by
// strict Iceberg clients reading directly from S3, and that the
// FullMetadata persisted in S3Tables stays consistent.
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
newMetadata, err = table.ParseMetadataBytes(metadataBytes)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse committed metadata: "+err.Error())
return
}
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+err.Error())
return
}
if err := confineMetadataLocation(metadataBucket, metadataPath, bucketName); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
metadataFileName, newMetadataLocation, err = s.stageCommitMetadata(r.Context(), metadataBucket, metadataPath, location, metadataFileName, metadataBytes)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
VersionToken: getResp.VersionToken,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
TableUUID: tableUUID.String(),
},
FullMetadata: metadataBytes,
},
MetadataVersion: metadataVersion,
MetadataLocation: newMetadataLocation,
}
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateTable", updateReq, nil, identityName)
})
if err == nil {
result := CommitTableResponse{
MetadataLocation: newMetadataLocation,
Metadata: newMetadata,
}
writeJSON(w, http.StatusOK, result)
return
}
if isS3TablesConflict(err) {
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s on conflict: %v", newMetadataLocation, cleanupErr)
}
if attempt < maxCommitAttempts {
glog.V(1).Infof("Iceberg: CommitTable conflict for %s (attempt %d/%d), retrying", tableName, attempt, maxCommitAttempts)
sleepBeforeCommitRetry(attempt)
continue
}
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
}
glog.Errorf("Iceberg: CommitTable UpdateTable error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit table update: "+err.Error())
return
}
}

View File

@@ -0,0 +1,58 @@
package iceberg
import (
"encoding/json"
"io"
"net/http"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// Reports carry per-file scan metrics and grow with the table. The content is
// discarded either way, so anything past this is accepted without being read
// into memory rather than rejected.
const maxMetricsReportBytes = 1 << 20
// handleReportMetrics accepts the scan and commit reports engines send after
// planning or committing. The catalog keeps no metrics store, but the endpoint
// has to exist: clients that get a 404 here log an error per query, and some
// treat repeated failures as a broken catalog.
func (s *Server) handleReportMetrics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
return
}
// One byte past the limit distinguishes a report that fits from one that
// was cut short, which would otherwise fail to parse and look malformed.
body, err := io.ReadAll(io.LimitReader(r.Body, maxMetricsReportBytes+1))
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
if len(body) > maxMetricsReportBytes {
glog.V(3).Infof("Iceberg: discarding oversized metrics report for %s.%s", flattenNamespacePath(namespace), tableName)
w.WriteHeader(http.StatusNoContent)
return
}
var report struct {
ReportType string `json:"report-type"`
}
if err := json.Unmarshal(body, &report); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
return
}
if report.ReportType == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "report-type is required")
return
}
glog.V(3).Infof("Iceberg: metrics report %q for %s.%s", report.ReportType, flattenNamespacePath(namespace), tableName)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,63 @@
package iceberg
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
)
func postMetrics(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/ns/tables/t/metrics", strings.NewReader(body))
r = mux.SetURLVars(r, map[string]string{"namespace": "ns", "table": "t"})
w := httptest.NewRecorder()
(&Server{}).handleReportMetrics(w, r)
return w
}
func TestHandleReportMetricsAcceptsScanReport(t *testing.T) {
w := postMetrics(t, `{"report-type":"scan-report","table-name":"t","snapshot-id":1}`)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d: %s", w.Code, http.StatusNoContent, w.Body.String())
}
}
func TestHandleReportMetricsRejectsGarbage(t *testing.T) {
w := postMetrics(t, `not json`)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
// The spec makes the body and its report-type required.
func TestHandleReportMetricsRequiresReportType(t *testing.T) {
for _, body := range []string{``, `{}`, `{"report-type":""}`} {
if w := postMetrics(t, body); w.Code != http.StatusBadRequest {
t.Errorf("body %q: status = %d, want %d", body, w.Code, http.StatusBadRequest)
}
}
}
// A report larger than the read limit is still a valid report; truncating it
// and then failing to parse would hand the client an error for a query that
// worked.
func TestHandleReportMetricsAcceptsOversizedReport(t *testing.T) {
padding := strings.Repeat("x", maxMetricsReportBytes)
w := postMetrics(t, `{"report-type":"scan-report","padding":"`+padding+`"}`)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d: %s", w.Code, http.StatusNoContent, w.Body.String())
}
}
func TestHandleReportMetricsRequiresTable(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/ns/tables//metrics", strings.NewReader(`{}`))
r = mux.SetURLVars(r, map[string]string{"namespace": "ns"})
w := httptest.NewRecorder()
(&Server{}).handleReportMetrics(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}

View File

@@ -0,0 +1,389 @@
package iceberg
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// handleConfig returns catalog configuration.
//
// When a client passes ?warehouse=, the Iceberg REST spec expects the server
// to echo a catalog identifier back as overrides.prefix so subsequent calls
// use /v1/{prefix}/... and land on the right table bucket. Without this,
// clients like DuckDB's ATTACH flow fall back to an unprefixed path that
// resolves to the wrong bucket and report phantom "schema does not exist"
// errors.
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
config := CatalogConfig{
Defaults: map[string]string{},
Overrides: map[string]string{},
}
if warehouse := strings.TrimSpace(r.URL.Query().Get("warehouse")); warehouse != "" {
// Only the bucket portion of the warehouse is meaningful today —
// SeaweedFS table-bucket routing is bucket-scoped, so any sub-path
// (e.g. s3://bucket/prefix/) is ignored here and clients that try to
// scope a catalog under a sub-prefix will still land on the bucket.
if bucket := resolveWarehouseBucket(warehouse); bucket != "" {
config.Overrides["prefix"] = bucket
// Echo the location form regardless of how the client spelled the
// warehouse: clients derive default table locations from it.
config.Defaults["warehouse"] = "s3://" + bucket
}
}
if err := json.NewEncoder(w).Encode(config); err != nil {
glog.Warningf("handleConfig: Failed to encode config: %v", err)
}
}
// handleListNamespaces lists namespaces in a catalog.
func (s *Server) handleListNamespaces(w http.ResponseWriter, r *http.Request) {
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
pageToken, pageSize, err := parsePagination(r)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
// The Iceberg REST spec allows a "parent" query parameter for hierarchical
// namespace listing. Convert it to the dot-separated prefix used by S3 Tables.
var prefix string
if parent := r.URL.Query().Get("parent"); parent != "" {
parentParts := parseNamespace(parent)
if len(parentParts) > 0 {
prefix = flattenNamespacePath(parentParts) + "."
}
}
// Use S3 Tables manager to list namespaces
var resp s3tables.ListNamespacesResponse
req := &s3tables.ListNamespacesRequest{
TableBucketARN: bucketARN,
Prefix: prefix,
ContinuationToken: pageToken,
MaxNamespaces: pageSize,
}
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "ListNamespaces", req, &resp, identityName)
})
if err != nil {
glog.Infof("Iceberg: ListNamespaces error: %v", err)
writeManagerError(w, err)
return
}
// Convert to Iceberg format
namespaces := make([]Namespace, 0, len(resp.Namespaces))
for _, ns := range resp.Namespaces {
namespaces = append(namespaces, Namespace(ns.Namespace))
}
result := ListNamespacesResponse{
NextPageToken: resp.ContinuationToken,
Namespaces: namespaces,
}
writeJSON(w, http.StatusOK, result)
}
// handleCreateNamespace creates a new namespace.
func (s *Server) handleCreateNamespace(w http.ResponseWriter, r *http.Request) {
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
var req CreateNamespaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
if len(req.Namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
// Use S3 Tables manager to create namespace
createReq := &s3tables.CreateNamespaceRequest{
TableBucketARN: bucketARN,
Namespace: req.Namespace,
Properties: normalizeNamespaceProperties(req.Properties),
}
var createResp s3tables.CreateNamespaceResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
glog.V(2).Infof("Iceberg: handleCreateNamespace calling Execute with identityName=%s", identityName)
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateNamespace", createReq, &createResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "already exists") {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
glog.Errorf("Iceberg: CreateNamespace error: %v", err)
writeManagerError(w, err)
return
}
result := CreateNamespaceResponse{
Namespace: Namespace(createResp.Namespace),
Properties: withDefaultNamespaceLocation(createResp.Properties, bucketName, createResp.Namespace),
}
writeJSON(w, http.StatusOK, result)
}
// handleGetNamespace gets namespace metadata.
func (s *Server) handleGetNamespace(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Use S3 Tables manager to get namespace
getReq := &s3tables.GetNamespaceRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
}
var getResp s3tables.GetNamespaceResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetNamespace", getReq, &getResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
}
glog.V(1).Infof("Iceberg: GetNamespace error: %v", err)
writeManagerError(w, err)
return
}
result := GetNamespaceResponse{
Namespace: Namespace(getResp.Namespace),
Properties: withDefaultNamespaceLocation(getResp.Properties, bucketName, getResp.Namespace),
}
writeJSON(w, http.StatusOK, result)
}
// applyNamespacePropertyUpdates applies removals and updates to a copy of
// current and returns the merged property map alongside the Iceberg REST
// summary of which keys were removed, updated, or were missing.
func applyNamespacePropertyUpdates(current map[string]string, removals []string, updates map[string]string) (map[string]string, UpdateNamespacePropertiesResponse) {
properties := make(map[string]string, len(current))
for k, v := range current {
properties[k] = v
}
summary := UpdateNamespacePropertiesResponse{Removed: []string{}, Updated: []string{}, Missing: []string{}}
seen := make(map[string]struct{}, len(removals))
for _, key := range removals {
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
if _, ok := properties[key]; ok {
delete(properties, key)
summary.Removed = append(summary.Removed, key)
} else {
summary.Missing = append(summary.Missing, key)
}
}
for key, value := range updates {
properties[key] = value
summary.Updated = append(summary.Updated, key)
}
return properties, summary
}
// handleUpdateNamespaceProperties applies a set of removals and updates to a
// namespace's properties and returns which keys were removed, updated, or
// missing, per the Iceberg REST spec.
func (s *Server) handleUpdateNamespaceProperties(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
var req UpdateNamespacePropertiesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
// A key cannot be both removed and updated in the same request.
for _, key := range req.Removals {
if _, ok := req.Updates[key]; ok {
writeError(w, http.StatusUnprocessableEntity, "UnprocessableEntityException",
fmt.Sprintf("Cannot remove and update the same key: %s", key))
return
}
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
// Load the current properties so we can compute the summary and the merged map.
getReq := &s3tables.GetNamespaceRequest{TableBucketARN: bucketARN, Namespace: namespace}
var getResp s3tables.GetNamespaceResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetNamespace", getReq, &getResp, identityName)
})
if err != nil {
writeNamespaceManagerError(w, err, namespace)
return
}
properties, summary := applyNamespacePropertyUpdates(getResp.Properties, req.Removals, req.Updates)
updReq := &s3tables.UpdateNamespaceRequest{TableBucketARN: bucketARN, Namespace: namespace, Properties: properties}
var updResp s3tables.UpdateNamespaceResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateNamespace", updReq, &updResp, identityName)
})
if err != nil {
writeNamespaceManagerError(w, err, namespace)
return
}
writeJSON(w, http.StatusOK, summary)
}
// writeNamespaceManagerError maps an s3tables manager error to the matching
// Iceberg REST response. A namespace dropped between read and write surfaces
// as 404 and a denied caller as 403; anything else is a 500.
func writeNamespaceManagerError(w http.ResponseWriter, err error, namespace Namespace) {
var s3Err *s3tables.S3TablesError
if errors.As(err, &s3Err) {
switch s3Err.Type {
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
case s3tables.ErrCodeAccessDenied:
writeError(w, http.StatusForbidden, "ForbiddenException", "Not authorized to update namespace properties")
return
}
}
glog.V(1).Infof("Iceberg: UpdateNamespaceProperties error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
}
// handleNamespaceExists checks if a namespace exists.
func (s *Server) handleNamespaceExists(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
getReq := &s3tables.GetNamespaceRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
}
var getResp s3tables.GetNamespaceResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetNamespace", getReq, &getResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
w.WriteHeader(http.StatusNotFound)
return
}
if nameValidationError(err) {
w.WriteHeader(http.StatusBadRequest)
return
}
glog.V(1).Infof("Iceberg: NamespaceExists error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDropNamespace deletes a namespace.
func (s *Server) handleDropNamespace(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
deleteReq := &s3tables.DeleteNamespaceRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteNamespace", deleteReq, nil, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
}
if strings.Contains(err.Error(), "not empty") {
writeError(w, http.StatusConflict, "NamespaceNotEmptyException", "Namespace is not empty")
return
}
glog.V(1).Infof("Iceberg: DropNamespace error: %v", err)
writeManagerError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,397 @@
package iceberg
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// OAuthTokenResponse is the response for POST /v1/oauth/tokens.
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope,omitempty"`
// IssuedTokenType is set to the access-token URN on token-exchange
// responses, where RFC 8693 requires it.
IssuedTokenType string `json:"issued_token_type,omitempty"`
}
// OAuthErrorResponse is the error response for the OAuth endpoint.
type OAuthErrorResponse struct {
Error string `json:"error"`
Description string `json:"error_description,omitempty"`
}
// IcebergClaims are JWT claims for Iceberg catalog OAuth tokens.
type IcebergClaims struct {
IdentityName string `json:"identity_name"`
AccessKey string `json:"access_key"`
jwt.RegisteredClaims
}
const defaultOauthTokenExpiry = 3600
// maxOauthTokenExpiry bounds the configured TTL so seconds-to-Duration
// conversions cannot overflow into already-expired tokens.
const maxOauthTokenExpiry = 365 * 24 * 3600
// accessTokenTokenType is the RFC 8693 token-type URN for access tokens,
// required in issued_token_type on token-exchange responses.
const accessTokenTokenType = "urn:ietf:params:oauth:token-type:access_token"
// grant types accepted by POST /v1/oauth/tokens. Iceberg Java 1.10.x
// refreshes tokens via token-exchange (exchangeEnabled defaults true), so a
// server that only accepts client_credentials leaves those clients unable
// to refresh.
const (
grantTypeClientCredentials = "client_credentials"
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
)
// oauthExpirySeconds returns the OAuth token TTL. Deployments whose
// clients cannot refresh tokens on 401 can raise this to survive client
// restart cycles instead of dying every hour.
func oauthExpirySeconds() int {
if v := os.Getenv("ICEBERG_OAUTH_TOKEN_EXPIRY"); v != "" {
// Parse in 64-bit space: on 32-bit platforms Atoi overflows and
// errors on oversized values, which would silently fall back to
// the default instead of clamping.
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
if n > maxOauthTokenExpiry {
return maxOauthTokenExpiry
}
return int(n)
}
}
return defaultOauthTokenExpiry
}
// tokenExchangeGrace is how long an expired subject token may still be
// exchanged for a fresh access token. Signature verification is the real
// gate; the grace exists so a client holding a token that expired while the
// exchange grant was unsupported (or during a server outage) recovers
// without a process restart.
func tokenExchangeGrace() time.Duration {
grace := 2 * oauthExpirySeconds()
if grace < 3600 {
grace = 3600
}
if grace > 86400 {
grace = 86400
}
return time.Duration(grace) * time.Second
}
// handleOAuthTokens implements the OAuth2 client_credentials flow.
// POST /v1/oauth/tokens
func (s *Server) handleOAuthTokens(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Could not parse form body")
return
}
// Reject credentials in query string to prevent leaking secrets into logs and caches.
if r.URL.Query().Get("client_secret") != "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_secret must not be sent in the URL")
return
}
grantType := r.PostFormValue("grant_type")
switch grantType {
case grantTypeClientCredentials:
// handled below
case grantTypeTokenExchange, "token_exchange":
s.handleTokenExchange(w, r)
return
default:
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
fmt.Sprintf("Unsupported grant_type: %s", grantType))
return
}
clientID := r.PostFormValue("client_id")
clientSecret := r.PostFormValue("client_secret")
// Also support HTTP Basic auth per OAuth2 spec
if clientID == "" && clientSecret == "" {
var ok bool
clientID, clientSecret, ok = r.BasicAuth()
if !ok {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Missing client credentials")
return
}
}
if clientID == "" || clientSecret == "" {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Missing client_id or client_secret")
return
}
if s.credentialValidator == nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Credential validation not configured")
return
}
identityName, _, err := s.credentialValidator.ValidateS3Credential(clientID, clientSecret)
if err != nil {
glog.V(2).Infof("Iceberg OAuth: credential validation failed for client_id=%s: %v", clientID, err)
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Invalid client credentials")
return
}
// Generate a JWT signed with a key derived from the client secret.
// Include the access key in claims so we can look up the exact credential for verification.
tokenString, err := mintIcebergToken(identityName, clientID, clientSecret, oauthExpirySeconds())
if err != nil {
glog.Errorf("Iceberg OAuth: failed to sign token: %v", err)
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token")
return
}
scope := r.PostFormValue("scope")
resp := OAuthTokenResponse{
AccessToken: tokenString,
TokenType: "bearer",
ExpiresIn: oauthExpirySeconds(),
Scope: scope,
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, resp)
}
// handleTokenExchange implements RFC 8693 token exchange for Iceberg REST
// clients: a previously issued access token (subject_token) is exchanged for
// a fresh one. Iceberg Java's OAuth2Manager refreshes via this grant
// (exchangeEnabled defaults true), so supporting it lets those clients
// self-heal before their token expires — no client restart needed.
//
// Client authentication (RFC 8693 §2.1) is accepted but not required:
// Iceberg Java's refreshExpiredToken sends Basic credentials with the
// exchange, while its proactive scheduled refresh sends only the Bearer
// session headers. An expired subject_token is only exchangeable with valid
// client credentials, and an unauthenticated exchange never extends the
// token's lifetime beyond the subject_token's own expiry.
func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request) {
if s.credentialValidator == nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Credential validation not configured")
return
}
subjectToken := r.PostFormValue("subject_token")
if subjectToken == "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Missing subject_token")
return
}
// Verify the subject token by signature (exp checked separately against
// the recovery grace).
unverified := &IcebergClaims{}
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
if _, _, err := parser.ParseUnverified(subjectToken, unverified); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
return
}
if unverified.AccessKey == "" || unverified.Issuer != "seaweedfs-iceberg" {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
return
}
identityName, _, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey)
if err != nil {
glog.V(2).Infof("Iceberg OAuth: token exchange failed to get credential for access key: %v", err)
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
return
}
// Optional client authentication. When present, the client must
// authenticate with the same credential that issued the subject_token.
clientID, clientSecret, hasBasic := r.BasicAuth()
if !hasBasic {
clientID = r.PostFormValue("client_id")
clientSecret = r.PostFormValue("client_secret")
hasBasic = clientID != "" && clientSecret != ""
}
clientAuthenticated := false
if hasBasic {
if _, _, err := s.credentialValidator.ValidateS3Credential(clientID, clientSecret); err != nil {
glog.V(2).Infof("Iceberg OAuth: token exchange client authentication failed for client_id=%s: %v", clientID, err)
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Invalid client credentials")
return
}
if clientID != unverified.AccessKey {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token was issued to a different client")
return
}
clientAuthenticated = true
}
signingKey := deriveSigningKey(unverified.AccessKey, secretKey)
claims := &IcebergClaims{}
parsed, err := jwt.ParseWithClaims(subjectToken, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return signingKey, nil
}, jwt.WithoutClaimsValidation())
if err != nil || !parsed.Valid {
glog.V(2).Infof("Iceberg OAuth: token exchange signature verification failed: %v", err)
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
return
}
if claims.Issuer != "seaweedfs-iceberg" {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
return
}
if claims.ExpiresAt == nil {
// Tokens minted by this server always carry an expiry; a subject
// token without one must not be exchangeable forever.
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token has no expiry")
return
}
ttlSeconds := oauthExpirySeconds()
if time.Now().After(claims.ExpiresAt.Time) {
// Expired subject tokens are only exchangeable by an authenticated
// client, within the recovery grace.
if !clientAuthenticated {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant",
"subject_token is expired; client authentication is required to exchange it")
return
}
if time.Since(claims.ExpiresAt.Time) > tokenExchangeGrace() {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token expired beyond the exchange grace window")
return
}
} else if remaining := int(time.Until(claims.ExpiresAt.Time).Seconds()); !clientAuthenticated && remaining < ttlSeconds {
// Unauthenticated exchange (proactive refresh carrying only the
// Bearer) must not extend the lifetime past the subject's expiry;
// otherwise a leaked token could chain-refresh forever. An
// authenticated client gets a fresh full TTL — it could mint one
// via client_credentials anyway.
ttlSeconds = remaining
}
if ttlSeconds <= 0 {
// A subject token with under a second left would otherwise be
// exchanged for a token that is born expired.
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token has no remaining lifetime")
return
}
tokenString, err := mintIcebergToken(identityName, unverified.AccessKey, secretKey, ttlSeconds)
if err != nil {
glog.Errorf("Iceberg OAuth: failed to sign exchanged token: %v", err)
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token")
return
}
resp := OAuthTokenResponse{
AccessToken: tokenString,
TokenType: "bearer",
ExpiresIn: ttlSeconds,
Scope: r.PostFormValue("scope"),
IssuedTokenType: accessTokenTokenType,
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, resp)
}
// mintIcebergToken issues a signed access token for the given identity and
// credential, valid for ttlSeconds.
func mintIcebergToken(identityName, accessKey, secret string, ttlSeconds int) (string, error) {
signingKey := deriveSigningKey(accessKey, secret)
now := time.Now()
claims := IcebergClaims{
IdentityName: identityName,
AccessKey: accessKey,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)),
Issuer: "seaweedfs-iceberg",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(signingKey)
}
// authenticateBearer validates a Bearer token from the Authorization header.
// Returns the identity name, identity object, and whether auth succeeded.
func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) {
auth := r.Header.Get("Authorization")
if auth == "" {
return "", nil, false
}
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
return "", nil, false
}
tokenString := strings.TrimSpace(auth[7:])
if tokenString == "" {
return "", nil, false
}
if s.credentialValidator == nil {
return "", nil, false
}
// Parse the token without verification first to get the access key,
// then look up the exact credential to verify the signature.
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
unverified := &IcebergClaims{}
_, _, err := parser.ParseUnverified(tokenString, unverified)
if err != nil {
glog.V(2).Infof("Iceberg OAuth: failed to parse token: %v", err)
return "", nil, false
}
if unverified.AccessKey == "" {
return "", nil, false
}
// Look up the credential by access key to get the signing key for verification
identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey)
if err != nil {
glog.V(2).Infof("Iceberg OAuth: failed to get credential for access key: %v", err)
return "", nil, false
}
signingKey := deriveSigningKey(unverified.AccessKey, secretKey)
claims := &IcebergClaims{}
verified, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return signingKey, nil
})
if err != nil || !verified.Valid {
glog.V(2).Infof("Iceberg OAuth: token verification failed: %v", err)
return "", nil, false
}
return identityName, identity, true
}
// deriveSigningKey derives a signing key from the access key and secret using HMAC-SHA256.
// Including the access key prevents cross-credential token forgery when two
// credentials happen to share the same secret.
func deriveSigningKey(accessKey, secret string) []byte {
h := hmac.New(sha256.New, []byte("seaweedfs-iceberg-oauth"))
h.Write([]byte(accessKey))
h.Write([]byte{0}) // null separator
h.Write([]byte(secret))
return h.Sum(nil)
}
func writeOAuthError(w http.ResponseWriter, status int, errCode, description string) {
resp := OAuthErrorResponse{
Error: errCode,
Description: description,
}
writeJSON(w, status, resp)
}

View File

@@ -0,0 +1,410 @@
package iceberg
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
)
type mockCredentialValidator struct {
credentials map[string]string // accessKey -> secretKey
identities map[string]string // accessKey -> identityName
}
func (m *mockCredentialValidator) ValidateS3Credential(accessKey, secretKey string) (string, interface{}, error) {
expected, ok := m.credentials[accessKey]
if !ok {
return "", nil, fmt.Errorf("access key not found")
}
if expected != secretKey {
return "", nil, fmt.Errorf("invalid secret key")
}
return m.identities[accessKey], nil, nil
}
func (m *mockCredentialValidator) GetCredentialByAccessKey(accessKey string) (string, interface{}, string, error) {
secret, ok := m.credentials[accessKey]
if !ok {
return "", nil, "", fmt.Errorf("access key not found")
}
return m.identities[accessKey], nil, secret, nil
}
func newTestServerWithOAuth() *Server {
cv := &mockCredentialValidator{
credentials: map[string]string{"AKID123": "secret456"},
identities: map[string]string{"AKID123": "testuser"},
}
s := &Server{
credentialValidator: cv,
}
return s
}
func TestHandleOAuthTokens_Success(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.TokenType != "bearer" {
t.Errorf("expected token_type=bearer, got %s", resp.TokenType)
}
if resp.AccessToken == "" {
t.Error("expected non-empty access_token")
}
if resp.ExpiresIn != oauthExpirySeconds() {
t.Errorf("expected expires_in=%d, got %d", oauthExpirySeconds(), resp.ExpiresIn)
}
}
func TestHandleOAuthTokens_InvalidCredentials(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=AKID123&client_secret=wrongsecret"
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleOAuthTokens_UnsupportedGrantType(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=authorization_code&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBearerTokenRoundTrip(t *testing.T) {
s := newTestServerWithOAuth()
// Get a token
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
// Use the token for Bearer auth
authReq := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
authReq.Header.Set("Authorization", "Bearer "+resp.AccessToken)
identityName, _, ok := s.authenticateBearer(authReq)
if !ok {
t.Fatal("expected Bearer auth to succeed")
}
if identityName != "testuser" {
t.Errorf("expected identity 'testuser', got '%s'", identityName)
}
}
func TestBearerTokenInvalid(t *testing.T) {
s := newTestServerWithOAuth()
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
_, _, ok := s.authenticateBearer(req)
if ok {
t.Error("expected Bearer auth to fail with invalid token")
}
}
func TestBearerTokenNone(t *testing.T) {
s := newTestServerWithOAuth()
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
_, _, ok := s.authenticateBearer(req)
if ok {
t.Error("expected Bearer auth to fail with no token")
}
}
// TestOauthExpirySecondsEnvOverride pins the TTL knob: the token TTL must
// be configurable so clients that cannot refresh on 401 can be given
// longer-lived tokens instead of dying every hour.
func TestOauthExpirySecondsEnvOverride(t *testing.T) {
if got := oauthExpirySeconds(); got != defaultOauthTokenExpiry {
t.Fatalf("default TTL = %d, want %d", got, defaultOauthTokenExpiry)
}
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "86400")
if got := oauthExpirySeconds(); got != 86400 {
t.Fatalf("env TTL = %d, want 86400", got)
}
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "-5")
if got := oauthExpirySeconds(); got != defaultOauthTokenExpiry {
t.Fatalf("negative TTL must fall back to default, got %d", got)
}
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "garbage")
if got := oauthExpirySeconds(); got != defaultOauthTokenExpiry {
t.Fatalf("invalid TTL must fall back to default, got %d", got)
}
// oversized values are clamped so Duration math cannot overflow
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "99999999999999999")
if got := oauthExpirySeconds(); got != maxOauthTokenExpiry {
t.Fatalf("oversized TTL must clamp to %d, got %d", maxOauthTokenExpiry, got)
}
// a token minted under an override carries the override's expiry
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "7200")
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ExpiresIn != 7200 {
t.Fatalf("minted token expires_in = %d, want 7200", resp.ExpiresIn)
}
}
func exchangeRequest(t *testing.T, subjectToken string) *httptest.ResponseRecorder {
return exchangeRequestWithBasic(t, subjectToken, "", "")
}
// exchangeRequestWithBasic mirrors Iceberg Java: refreshExpiredToken sends
// Basic client credentials with the token-exchange grant, the proactive
// scheduled refresh sends only the form body.
func exchangeRequestWithBasic(t *testing.T, subjectToken, basicUser, basicPass string) *httptest.ResponseRecorder {
t.Helper()
s := newTestServerWithOAuth()
body := "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=" + url.QueryEscape(subjectToken)
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if basicUser != "" || basicPass != "" {
req.SetBasicAuth(basicUser, basicPass)
}
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
return w
}
// TestTokenExchangeLiveSubject: the proactive refresh path Iceberg Java
// 1.10.x uses (exchangeEnabled defaults true, Bearer-only headers) must
// mint a fresh working token.
func TestTokenExchangeLiveSubject(t *testing.T) {
s := newTestServerWithOAuth()
now := time.Now()
live := mintTestToken(t, "AKID123", "secret456", now, now.Add(30*time.Minute))
w := exchangeRequest(t, live)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.TokenType != "bearer" || resp.AccessToken == "" {
t.Fatalf("bad token response: %+v", resp)
}
if resp.IssuedTokenType != accessTokenTokenType {
t.Fatalf("issued_token_type = %q, want %s", resp.IssuedTokenType, accessTokenTokenType)
}
// Unauthenticated exchange must not outlive the subject token.
if resp.ExpiresIn > 30*60 {
t.Fatalf("expires_in = %d, must be bounded by the subject's remaining lifetime (<= 1800)", resp.ExpiresIn)
}
// the exchanged token must authenticate like a normal Bearer
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
req.Header.Set("Authorization", "Bearer "+resp.AccessToken)
if _, _, ok := s.authenticateBearer(req); !ok {
t.Fatalf("exchanged token must pass authenticateBearer")
}
}
// TestTokenExchangeExpiredWithinGrace: a client whose token expired while
// exchange was unsupported must recover without a restart. This is the
// refreshExpiredToken path, which authenticates with Basic credentials.
func TestTokenExchangeExpiredWithinGrace(t *testing.T) {
now := time.Now()
expiredRecently := mintTestToken(t, "AKID123", "secret456", now.Add(-20*time.Minute), now.Add(-10*time.Minute))
w := exchangeRequestWithBasic(t, expiredRecently, "AKID123", "secret456")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 within grace, got %d: %s", w.Code, w.Body.String())
}
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
// Authenticated refresh gets the full configured TTL.
if resp.ExpiresIn != oauthExpirySeconds() {
t.Fatalf("expires_in = %d, want %d", resp.ExpiresIn, oauthExpirySeconds())
}
}
// TestTokenExchangeAuthenticatedLiveSubjectFullTTL: an authenticated client
// exchanging a live token renews the session — the TTL cap only applies to
// unauthenticated (Bearer-only) exchanges.
func TestTokenExchangeAuthenticatedLiveSubjectFullTTL(t *testing.T) {
now := time.Now()
live := mintTestToken(t, "AKID123", "secret456", now, now.Add(30*time.Minute))
w := exchangeRequestWithBasic(t, live, "AKID123", "secret456")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ExpiresIn != oauthExpirySeconds() {
t.Fatalf("authenticated live exchange expires_in = %d, want full TTL %d", resp.ExpiresIn, oauthExpirySeconds())
}
}
// TestTokenExchangeSubjectNearlyExpired: a Bearer-only exchange when the
// subject token has under a second left must be rejected, not minted into
// an already-expired token (expires_in: 0).
func TestTokenExchangeSubjectNearlyExpired(t *testing.T) {
// jwt/v5 serializes exp at one-second precision, so pin the expiry to
// the next whole-second boundary: the token is always live when minted
// and exchanged, yet has under a second of remaining lifetime.
exp := time.Now().Truncate(time.Second).Add(time.Second)
nearlyDead := mintTestToken(t, "AKID123", "secret456", exp.Add(-time.Hour), exp)
w := exchangeRequest(t, nearlyDead)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "invalid_grant") {
t.Fatalf("expected invalid_grant, got: %s", w.Body.String())
}
}
// TestTokenExchangeExpiredWithoutClientAuth: a leaked expired token must
// not be exchangeable without client credentials.
func TestTokenExchangeExpiredWithoutClientAuth(t *testing.T) {
now := time.Now()
expiredRecently := mintTestToken(t, "AKID123", "secret456", now.Add(-20*time.Minute), now.Add(-10*time.Minute))
w := exchangeRequest(t, expiredRecently)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "invalid_grant") {
t.Fatalf("expected invalid_grant, got: %s", w.Body.String())
}
}
// TestTokenExchangeExpiredBeyondGrace: stale tokens far past expiry must not
// act as eternal credentials, even with client auth.
func TestTokenExchangeExpiredBeyondGrace(t *testing.T) {
now := time.Now()
longDead := mintTestToken(t, "AKID123", "secret456", now.Add(-48*time.Hour), now.Add(-47*time.Hour))
w := exchangeRequestWithBasic(t, longDead, "AKID123", "secret456")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 beyond grace, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "invalid_grant") {
t.Fatalf("expected invalid_grant, got: %s", w.Body.String())
}
}
// TestTokenExchangeGarbageSubject: malformed subject tokens are rejected
// with 400 invalid_grant per RFC 6749 §5.2.
func TestTokenExchangeGarbageSubject(t *testing.T) {
w := exchangeRequest(t, "not-a-jwt")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// TestTokenExchangeWrongClientCredentials: valid subject token, but Basic
// auth with wrong credentials or a different client is rejected.
func TestTokenExchangeWrongClientCredentials(t *testing.T) {
now := time.Now()
live := mintTestToken(t, "AKID123", "secret456", now, now.Add(30*time.Minute))
// wrong secret → invalid_client 401
w := exchangeRequestWithBasic(t, live, "AKID123", "wrongsecret")
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong secret: expected 401, got %d", w.Code)
}
// a different valid client must not exchange someone else's token
s := &Server{credentialValidator: &mockCredentialValidator{
credentials: map[string]string{"AKID123": "secret456", "AKID999": "other-secret"},
identities: map[string]string{"AKID123": "testuser", "AKID999": "otheruser"},
}}
body := "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=" + url.QueryEscape(live)
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("AKID999", "other-secret")
rec := httptest.NewRecorder()
s.handleOAuthTokens(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_grant") {
t.Fatalf("cross-client exchange: expected 400 invalid_grant, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestTokenExchangeSubjectWithoutExpiry: a correctly signed subject token
// without an exp claim must be rejected — it would otherwise refresh forever.
func TestTokenExchangeSubjectWithoutExpiry(t *testing.T) {
claims := IcebergClaims{
IdentityName: "testuser",
AccessKey: "AKID123",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "seaweedfs-iceberg",
// no ExpiresAt
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(deriveSigningKey("AKID123", "secret456"))
if err != nil {
t.Fatal(err)
}
w := exchangeRequestWithBasic(t, signed, "AKID123", "secret456")
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "invalid_grant") {
t.Fatalf("expected 400 invalid_grant for nil-exp subject, got %d: %s", w.Code, w.Body.String())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,364 @@
package iceberg
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path"
"strings"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// CommitTransactionRequest is sent to POST /v1/transactions/commit.
type CommitTransactionRequest struct {
TableChanges []tableChangeRequest `json:"table-changes"`
}
type tableChangeRequest struct {
Identifier *TableIdentifier `json:"identifier"`
Requirements json.RawMessage `json:"requirements"`
Updates []json.RawMessage `json:"updates"`
}
// preparedTableCommit holds the per-table work resolved during validation so
// pointer flips (and their rollback) can run after every table is validated.
type preparedTableCommit struct {
namespace []string
tableName string
tableUUID uuid.UUID
versionToken string
metadataBucket string
metadataPath string
location string
metadataFileName string
metadataBytes []byte
metadataVersion int
newMetadataLoc string
// prior table state captured before the flip, so a rollback can revert
// every field handleUpdateTable would have mutated (not just the location).
prevMetadataLoc string
prevMetadataVersion int
prevMetadata *s3tables.TableMetadata
}
// handleCommitTransaction commits changes to multiple tables in one request.
// Validation is atomic (all requirements evaluated before any write); pointer
// flips are best-effort with rollback, so this is not crash-atomic.
func (s *Server) handleCommitTransaction(w http.ResponseWriter, r *http.Request) {
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
var req CommitTransactionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
return
}
if len(req.TableChanges) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
// Phase 1: resolve, load, and validate every table; build new metadata bytes.
prepared := make([]preparedTableCommit, 0, len(req.TableChanges))
for _, change := range req.TableChanges {
if change.Identifier == nil || len(change.Identifier.Namespace) == 0 || change.Identifier.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Each table change requires identifier namespace and name")
return
}
pc, reqErr := s.prepareTableCommit(r.Context(), bucketName, bucketARN, identityName, change)
if reqErr != nil {
writeError(w, reqErr.status, reqErr.errType, reqErr.message)
return
}
prepared = append(prepared, *pc)
}
// Phase 2: write each new metadata.json object. Staging is exclusive for the
// same reason a single-table commit stages exclusively: a transaction racing
// another writer on one of its tables would otherwise overwrite that
// writer's metadata, and the pointer flip below decides who won.
for i := range prepared {
pc := &prepared[i]
fileName, location, err := s.stageCommitMetadata(r.Context(), pc.metadataBucket, pc.metadataPath, pc.location, pc.metadataFileName, pc.metadataBytes)
if err != nil {
// No pointer flipped yet, so every written file is safe to delete.
s.cleanupPreparedMetadata(r.Context(), prepared[:i+1], nil)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
pc.metadataFileName = fileName
pc.newMetadataLoc = location
}
// Phase 3: flip each table's pointer xattr; on failure roll back prior flips.
for i := range prepared {
pc := &prepared[i]
if err := s.flipTablePointer(r.Context(), bucketARN, identityName, pc); err != nil {
rolledBack := s.rollbackTablePointers(r.Context(), bucketARN, identityName, prepared[:i])
s.cleanupPreparedMetadata(r.Context(), prepared, cleanupSafeMetadata(prepared, i, rolledBack))
if isS3TablesConflict(err) {
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
glog.Errorf("Iceberg: CommitTransaction UpdateTable error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit table update: "+err.Error())
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) prepareTableCommit(ctx context.Context, bucketName, bucketARN, identityName string, change tableChangeRequest) (*preparedTableCommit, *icebergRequestError) {
namespace := []string(change.Identifier.Namespace)
tableName := change.Identifier.Name
requirements, updates, statisticsUpdates, reqErr := parseTableChange(change)
if reqErr != nil {
return nil, reqErr
}
getReq := &s3tables.GetTableRequest{TableBucketARN: bucketARN, Namespace: namespace, Name: tableName}
var getResp s3tables.GetTableResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(ctx, mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
if isS3TablesNotFound(err) {
return nil, &icebergRequestError{http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName)}
}
glog.V(1).Infof("Iceberg: CommitTransaction GetTable error: %v", err)
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", err.Error()}
}
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
}
tableUUID := uuid.Nil
if getResp.Metadata != nil && getResp.Metadata.Iceberg != nil && getResp.Metadata.Iceberg.TableUUID != "" {
if parsed, parseErr := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); parseErr == nil {
tableUUID = parsed
}
}
if tableUUID == uuid.Nil {
tableUUID = uuid.New()
}
var currentMetadata table.Metadata
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata"}
}
} else {
currentMetadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
if err != nil {
glog.Errorf("Iceberg: CommitTransaction placeholder metadata for %s: %v", tableName, err)
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata"}
}
}
for _, requirement := range requirements {
if err := requirement.Validate(currentMetadata); err != nil {
return nil, &icebergRequestError{http.StatusConflict, "CommitFailedException", "Requirement failed: " + err.Error()}
}
}
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: " + err.Error()}
}
for _, update := range updates {
if err := update.Apply(builder); err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to apply update: " + err.Error()}
}
}
newMetadata, err := builder.Build()
if err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: " + err.Error()}
}
metadataVersion := getResp.MetadataVersion + 1
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
metadataBytes, err := json.Marshal(newMetadata)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: " + err.Error()}
}
metadataBytes, err = applyStatisticsUpdates(metadataBytes, statisticsUpdates)
if err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: " + err.Error()}
}
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Invalid table location: " + err.Error()}
}
if err := confineMetadataLocation(metadataBucket, metadataPath, bucketName); err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", err.Error()}
}
return &preparedTableCommit{
namespace: namespace,
tableName: tableName,
tableUUID: tableUUID,
versionToken: getResp.VersionToken,
metadataBucket: metadataBucket,
metadataPath: metadataPath,
location: location,
metadataFileName: metadataFileName,
metadataBytes: metadataBytes,
metadataVersion: metadataVersion,
newMetadataLoc: newMetadataLocation,
prevMetadataLoc: getResp.MetadataLocation,
prevMetadataVersion: getResp.MetadataVersion,
prevMetadata: cloneTableMetadata(getResp.Metadata),
}, nil
}
// cloneTableMetadata deep-copies the prior table metadata so a later rollback
// restores the exact pre-transaction bytes without aliasing the get response.
func cloneTableMetadata(m *s3tables.TableMetadata) *s3tables.TableMetadata {
if m == nil {
return nil
}
clone := &s3tables.TableMetadata{}
if m.Iceberg != nil {
iceberg := *m.Iceberg
clone.Iceberg = &iceberg
}
if len(m.FullMetadata) > 0 {
clone.FullMetadata = append(json.RawMessage(nil), m.FullMetadata...)
}
return clone
}
func parseTableChange(change tableChangeRequest) (table.Requirements, table.Updates, []statisticsUpdate, *icebergRequestError) {
var requirements table.Requirements
if len(change.Requirements) > 0 {
normalized, err := normalizeRequirements(change.Requirements)
if err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
}
if err := json.Unmarshal(normalized, &requirements); err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
}
}
var updates table.Updates
var statisticsUpdates []statisticsUpdate
if len(change.Updates) > 0 {
var err error
updates, statisticsUpdates, err = parseCommitUpdates(change.Updates)
if err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid updates: " + err.Error()}
}
}
return requirements, updates, statisticsUpdates, nil
}
func (s *Server) flipTablePointer(ctx context.Context, bucketARN, identityName string, pc *preparedTableCommit) error {
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: pc.namespace,
Name: pc.tableName,
VersionToken: pc.versionToken,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: pc.tableUUID.String()},
FullMetadata: pc.metadataBytes,
},
MetadataVersion: pc.metadataVersion,
MetadataLocation: pc.newMetadataLoc,
}
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(ctx, mgrClient, "UpdateTable", updateReq, nil, identityName)
})
}
// rollbackTablePointers restores already-flipped tables to their full prior
// state. handleUpdateTable applies partial field updates, so the restore must
// re-send every field the flip changed (location, version, and full metadata),
// not just the location. Best-effort: a failed rollback is logged, not surfaced.
// The returned slice flags, per flipped table, whether the restore succeeded so
// the caller can avoid deleting metadata a still-flipped pointer references.
func (s *Server) rollbackTablePointers(ctx context.Context, bucketARN, identityName string, flipped []preparedTableCommit) []bool {
restored := make([]bool, len(flipped))
for i := range flipped {
pc := &flipped[i]
updateReq := buildTableRestoreRequest(bucketARN, pc)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(ctx, mgrClient, "UpdateTable", updateReq, nil, identityName)
})
if err != nil {
glog.Errorf("Iceberg: CommitTransaction rollback of %s failed: %v", pc.tableName, err)
continue
}
restored[i] = true
}
return restored
}
// buildTableRestoreRequest reconstructs the UpdateTableRequest that reverts a
// flipped table to its captured prior state. Every field handleUpdateTable
// would have mutated on the flip is re-supplied here so the partial update
// fully restores it (ModifiedAt and VersionToken are always regenerated by the
// handler and cannot be pinned back).
func buildTableRestoreRequest(bucketARN string, pc *preparedTableCommit) *s3tables.UpdateTableRequest {
return &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: pc.namespace,
Name: pc.tableName,
Metadata: cloneTableMetadata(pc.prevMetadata),
MetadataVersion: pc.prevMetadataVersion,
MetadataLocation: pc.prevMetadataLoc,
}
}
// cleanupSafeMetadata reports, per prepared table, whether deleting its newly
// written metadata file is safe after a flip failed at failedIndex. A file is
// safe to delete only when its table's pointer no longer references it: tables
// at or past failedIndex were never flipped, and earlier tables are safe only
// if their rollback succeeded. A table whose rollback failed still points at the
// new metadata, so deleting it would strand the pointer on a missing file.
func cleanupSafeMetadata(prepared []preparedTableCommit, failedIndex int, rolledBack []bool) []bool {
safe := make([]bool, len(prepared))
for i := range prepared {
switch {
case i >= failedIndex:
safe[i] = true
case i < len(rolledBack):
safe[i] = rolledBack[i]
}
}
return safe
}
// cleanupPreparedMetadata deletes the newly written metadata file for each
// prepared table flagged safe; unflagged tables are left in place because a
// still-flipped pointer references them.
func (s *Server) cleanupPreparedMetadata(ctx context.Context, prepared []preparedTableCommit, safe []bool) {
for i := range prepared {
pc := &prepared[i]
if i < len(safe) && !safe[i] {
glog.Errorf("Iceberg: CommitTransaction keeping metadata %s; %s rollback failed and still references it", pc.newMetadataLoc, pc.tableName)
continue
}
if cleanupErr := s.deleteMetadataFile(ctx, pc.metadataBucket, pc.metadataPath, pc.metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s: %v", pc.newMetadataLoc, cleanupErr)
}
}
}

View File

@@ -0,0 +1,449 @@
package iceberg
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path"
"strings"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/view"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// handleListViews lists views in a namespace.
func (s *Server) handleListViews(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
pageToken, pageSize, err := parsePagination(r)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
listReq := &s3tables.ListViewsRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
ContinuationToken: pageToken,
MaxViews: pageSize,
}
var listResp s3tables.ListViewsResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "ListViews", listReq, &listResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
}
glog.V(1).Infof("Iceberg: ListViews error: %v", err)
writeManagerError(w, err)
return
}
identifiers := make([]TableIdentifier, 0, len(listResp.Views))
for _, v := range listResp.Views {
identifiers = append(identifiers, TableIdentifier{Namespace: namespace, Name: v.Name})
}
writeJSON(w, http.StatusOK, ListViewsResponse{
NextPageToken: listResp.ContinuationToken,
Identifiers: identifiers,
})
}
// handleCreateView creates a new view and writes its v1 metadata.json.
func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
var req CreateViewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "view name is required")
return
}
if req.Schema == nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "view schema is required")
return
}
if req.ViewVersion == nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "view-version is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
viewPath := path.Join(flattenNamespacePath(namespace), req.Name)
location := strings.TrimSuffix(req.Location, "/")
if location == "" {
if req.Properties != nil {
if warehouse := strings.TrimSuffix(req.Properties["warehouse"], "/"); warehouse != "" {
location = fmt.Sprintf("%s/%s", warehouse, viewPath)
}
}
if location == "" {
if warehouse := strings.TrimSuffix(os.Getenv("ICEBERG_WAREHOUSE"), "/"); warehouse != "" {
location = fmt.Sprintf("%s/%s", warehouse, viewPath)
}
}
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, viewPath)
}
} else {
parsedBucket, parsedPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid view location: "+err.Error())
return
}
if parsedPath == "" {
location = fmt.Sprintf("s3://%s/%s", parsedBucket, viewPath)
}
}
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid view location: "+err.Error())
return
}
if metadataBucket != bucketName {
writeError(w, http.StatusBadRequest, "BadRequestException", "view location must be within bucket "+bucketName)
return
}
if !isValidTablePath(metadataPath) {
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid view location path")
return
}
metadata, err := view.NewMetadata(req.ViewVersion, req.Schema, location, viewProperties(req.Properties))
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build view metadata: "+err.Error())
return
}
metadataFileName := "v1.metadata.json"
metadataLocation := fmt.Sprintf("%s/metadata/%s", location, metadataFileName)
metadataBytes, err := json.Marshal(metadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize view metadata: "+err.Error())
return
}
// Authoritative existence check before touching storage: a registered view
// short-circuits with its stored definition (idempotent CreateView) so we
// never overwrite the persisted metadata of an existing view.
if existsResp, existsErr := s.getView(r, namespace, req.Name); existsErr == nil {
result, buildErr := s.buildViewResponse(r, existsResp, bucketName, namespace, req.Name)
if buildErr != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", buildErr.Error())
return
}
writeLoadResult(w, http.StatusOK, result)
return
} else if !isViewNotFound(existsErr) {
glog.V(1).Infof("Iceberg: CreateView existence check failed for %s.%s: %v", flattenNamespacePath(namespace), req.Name, existsErr)
writeManagerError(w, existsErr)
return
}
createReq := &s3tables.CreateViewRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: req.Name,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: metadata.ViewUUID().String()},
FullMetadata: metadataBytes,
},
MetadataLocation: metadataLocation,
MetadataVersion: 1,
}
var createResp s3tables.CreateViewResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateView", createReq, &createResp, identityName)
})
if err != nil {
if isViewAlreadyExists(err) {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
var viewErr *s3tables.S3TablesError
if errors.As(err, &viewErr) && viewErr.Type == s3tables.ErrCodeNoSuchNamespace {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
}
glog.V(1).Infof("Iceberg: CreateView error: %v", err)
writeManagerError(w, err)
return
}
// Persist the metadata file only after the catalog registers the view, so a
// missing namespace or name collision fails before any bytes hit storage.
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
// Roll back the registered view so it doesn't linger pointing at metadata
// that was never written.
if dropErr := s.dropView(r, namespace, req.Name); dropErr != nil {
glog.V(1).Infof("Iceberg: failed to roll back view %s.%s after metadata write error: %v", flattenNamespacePath(namespace), req.Name, dropErr)
}
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save view metadata file: "+err.Error())
return
}
finalLocation := createResp.MetadataLocation
if finalLocation == "" {
finalLocation = metadataLocation
}
config, _ := s.buildFileIOConfig(r, location)
writeLoadResult(w, http.StatusOK, ViewResponse{
MetadataLocation: finalLocation,
Metadata: metadata,
Config: config,
})
}
// handleLoadView loads view metadata.
func (s *Server) handleLoadView(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
viewName := vars["view"]
if len(namespace) == 0 || viewName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
return
}
getResp, err := s.getView(r, namespace, viewName)
if err != nil {
if isViewNotFound(err) {
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
return
}
glog.V(1).Infof("Iceberg: LoadView error: %v", err)
writeManagerError(w, err)
return
}
result, err := s.buildViewResponse(r, getResp, getBucketFromPrefix(r), namespace, viewName)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
return
}
writeLoadResult(w, http.StatusOK, result)
}
// handleViewExists checks if a view exists.
func (s *Server) handleViewExists(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
viewName := vars["view"]
if len(namespace) == 0 || viewName == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if _, err := s.getView(r, namespace, viewName); err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDropView deletes a view.
func (s *Server) handleDropView(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
viewName := vars["view"]
if len(namespace) == 0 || viewName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
return
}
var storedMetadataLocation string
if getResp, err := s.getView(r, namespace, viewName); err == nil {
storedMetadataLocation = getResp.MetadataLocation
}
err := s.dropView(r, namespace, viewName)
if err != nil {
if isViewNotFound(err) {
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
return
}
glog.V(1).Infof("Iceberg: DropView error: %v", err)
writeManagerError(w, err)
return
}
if storedMetadataLocation != "" {
if viewLoc := tableLocationFromMetadataLocation(storedMetadataLocation); viewLoc != "" {
if dataBucket, dataPath, parseErr := parseS3Location(viewLoc); parseErr == nil {
if cleanupErr := s.cleanupStaleTableLocation(r.Context(), dataBucket, dataPath); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to purge dropped view location s3://%s/%s: %v", dataBucket, dataPath, cleanupErr)
}
}
}
}
w.WriteHeader(http.StatusNoContent)
}
// dropView removes a view registration from the catalog.
func (s *Server) dropView(r *http.Request, namespace []string, viewName string) error {
deleteReq := &s3tables.DeleteViewRequest{
TableBucketARN: buildTableBucketARN(getBucketFromPrefix(r)),
Namespace: namespace,
Name: viewName,
}
identityName := s3_constants.GetIdentityNameFromContext(r)
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteView", deleteReq, nil, identityName)
})
}
// getView fetches a view's stored metadata pointer from the catalog.
func (s *Server) getView(r *http.Request, namespace []string, viewName string) (s3tables.GetViewResponse, error) {
getReq := &s3tables.GetViewRequest{
TableBucketARN: buildTableBucketARN(getBucketFromPrefix(r)),
Namespace: namespace,
Name: viewName,
}
var getResp s3tables.GetViewResponse
identityName := s3_constants.GetIdentityNameFromContext(r)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetView", getReq, &getResp, identityName)
})
return getResp, err
}
// buildViewResponse parses the stored view metadata into a ViewResponse.
func (s *Server) buildViewResponse(r *http.Request, getResp s3tables.GetViewResponse, bucketName string, namespace []string, viewName string) (ViewResponse, error) {
if getResp.Metadata == nil || len(getResp.Metadata.FullMetadata) == 0 {
return ViewResponse{}, fmt.Errorf("view %s has no metadata", viewName)
}
metadata, err := view.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
return ViewResponse{}, fmt.Errorf("failed to parse view metadata: %w", err)
}
config, _ := s.buildFileIOConfig(r, tableLocationFromMetadataLocation(getResp.MetadataLocation))
return ViewResponse{
MetadataLocation: getResp.MetadataLocation,
Metadata: metadata,
Config: config,
}, nil
}
// viewProperties returns a non-nil copy of props for view metadata construction.
func viewProperties(props iceberg.Properties) iceberg.Properties {
out := make(iceberg.Properties, len(props))
for k, v := range props {
out[k] = v
}
return out
}
func isViewNotFound(err error) bool {
if err == nil {
return false
}
var viewErr *s3tables.S3TablesError
if errors.As(err, &viewErr) {
if viewErr.Type == s3tables.ErrCodeNoSuchView || viewErr.Type == s3tables.ErrCodeNoSuchNamespace {
return true
}
}
return strings.Contains(strings.ToLower(err.Error()), "not found")
}
func isViewAlreadyExists(err error) bool {
if err == nil {
return false
}
var viewErr *s3tables.S3TablesError
if errors.As(err, &viewErr) && viewErr.Type == s3tables.ErrCodeViewAlreadyExists {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "already exists")
}
// handleRenameView moves a view's catalog pointer to a new namespace/name,
// the view counterpart of POST /v1/{prefix}/tables/rename.
func (s *Server) handleRenameView(w http.ResponseWriter, r *http.Request) {
var req RenameTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
source := parseNamespace(encodeNamespace(req.Source.Namespace))
dest := parseNamespace(encodeNamespace(req.Destination.Namespace))
if len(source) == 0 || req.Source.Name == "" || len(dest) == 0 || req.Destination.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "source and destination namespace and name are required")
return
}
bucketName := getBucketFromPrefix(r)
identityName := s3_constants.GetIdentityNameFromContext(r)
renameReq := &s3tables.RenameTableRequest{
TableBucketARN: buildTableBucketARN(bucketName),
SourceNamespace: source,
SourceName: req.Source.Name,
DestNamespace: dest,
DestName: req.Destination.Name,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "RenameView", renameReq, nil, identityName)
})
if err != nil {
var viewErr *s3tables.S3TablesError
if errors.As(err, &viewErr) {
switch viewErr.Type {
case s3tables.ErrCodeNoSuchView:
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", req.Source.Name))
return
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", dest))
return
case s3tables.ErrCodeViewAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("View already exists: %s", req.Destination.Name))
return
case s3tables.ErrCodeInvalidRequest:
writeError(w, http.StatusBadRequest, "BadRequestException", viewErr.Message)
return
}
}
glog.V(1).Infof("Iceberg: RenameView error: %v", err)
writeManagerError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,165 @@
package iceberg
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/apache/iceberg-go/view"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// handleUpdateView applies requirements and updates to a view, writes a new
// metadata.json, and flips the stored pointer. Mirrors the table commit flow.
func (s *Server) handleUpdateView(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
viewName := vars["view"]
if len(namespace) == 0 || viewName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
return
}
var req UpdateViewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
const maxCommitAttempts = 3
for attempt := 1; attempt <= maxCommitAttempts; attempt++ {
getResp, err := s.getView(r, namespace, viewName)
if err != nil {
if isViewNotFound(err) {
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
return
}
glog.V(1).Infof("Iceberg: UpdateView GetView error: %v", err)
writeManagerError(w, err)
return
}
if getResp.Metadata == nil || len(getResp.Metadata.FullMetadata) == 0 {
writeError(w, http.StatusInternalServerError, "InternalServerError", "view has no metadata")
return
}
currentMetadata, err := view.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse current view metadata: "+err.Error())
return
}
for _, requirement := range req.Requirements {
if err := requirement.Validate(currentMetadata); err != nil {
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+err.Error())
return
}
}
builder, err := view.MetadataBuilderFromBase(currentMetadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create view metadata builder: "+err.Error())
return
}
for _, update := range req.Updates {
if err := update.Apply(builder); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply update: "+err.Error())
return
}
}
newMetadata, err := builder.Build()
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build new view metadata: "+err.Error())
return
}
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = newMetadata.Location()
}
metadataVersion := getResp.MetadataVersion + 1
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
metadataBytes, err := json.Marshal(newMetadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize view metadata: "+err.Error())
return
}
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid view location: "+err.Error())
return
}
if err := confineMetadataLocation(metadataBucket, metadataPath, bucketName); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
metadataFileName, newMetadataLocation, err = s.stageCommitMetadata(r.Context(), metadataBucket, metadataPath, location, metadataFileName, metadataBytes)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save view metadata file: "+err.Error())
return
}
tableUUID := newMetadata.ViewUUID()
if tableUUID == uuid.Nil {
tableUUID = currentMetadata.ViewUUID()
}
updateReq := &s3tables.UpdateViewRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: viewName,
VersionToken: getResp.VersionToken,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: tableUUID.String()},
FullMetadata: metadataBytes,
},
MetadataVersion: metadataVersion,
MetadataLocation: newMetadataLocation,
}
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateView", updateReq, nil, identityName)
})
if err == nil {
config, _ := s.buildFileIOConfig(r, location)
writeLoadResult(w, http.StatusOK, ViewResponse{
MetadataLocation: newMetadataLocation,
Metadata: newMetadata,
Config: config,
})
return
}
if isS3TablesConflict(err) {
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup view metadata file %s on conflict: %v", newMetadataLocation, cleanupErr)
}
if attempt < maxCommitAttempts {
glog.V(1).Infof("Iceberg: UpdateView conflict for %s (attempt %d/%d), retrying", viewName, attempt, maxCommitAttempts)
sleepBeforeCommitRetry(attempt)
continue
}
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup view metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
}
glog.Errorf("Iceberg: UpdateView error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit view update: "+err.Error())
return
}
}

View File

@@ -0,0 +1,4 @@
// Package iceberg provides Iceberg REST Catalog API support.
// It implements the Apache Iceberg REST Catalog specification
// backed by S3 Tables metadata storage.
package iceberg

View File

@@ -0,0 +1,79 @@
package iceberg
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestWantsVendedCredentials(t *testing.T) {
tests := []struct {
name string
values []string
want bool
}{
{name: "no header", want: false},
{name: "vended credentials", values: []string{"vended-credentials"}, want: true},
{name: "mechanism list", values: []string{"remote-signing,vended-credentials"}, want: true},
{name: "spaced and mixed case", values: []string{"Remote-Signing, Vended-Credentials"}, want: true},
{name: "repeated header", values: []string{"remote-signing", "vended-credentials"}, want: true},
{name: "remote signing only", values: []string{"remote-signing"}, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
for _, v := range tc.values {
r.Header.Add(accessDelegationHeader, v)
}
if got := wantsVendedCredentials(r); got != tc.want {
t.Fatalf("wantsVendedCredentials(%v) = %v, want %v", tc.values, got, tc.want)
}
})
}
}
// A client that asked for vended credentials replaces its own storage
// credentials with whatever the catalog returns. With no vending configured
// there are none, so an endpoint by itself would leave the client signing
// nothing and every data file read and write would come back 403.
func TestBuildFileIOConfigWithholdsEndpointFromCredentialVendingClients(t *testing.T) {
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
if got, _ := s.buildFileIOConfig(r, "s3://warehouse/ns/t"); len(got) != 0 {
t.Fatalf("buildFileIOConfig() = %v, want empty for a vended-credentials request", got)
}
plain := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
if got, _ := s.buildFileIOConfig(plain, "s3://warehouse/ns/t"); got["s3.endpoint"] != s.s3Endpoint {
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], s.s3Endpoint)
}
}
// The body of a load response depends on the delegation header, so a cache in
// front of the catalog must not key on the URL alone.
func TestWriteLoadResultVariesOnTheDelegationHeader(t *testing.T) {
rec := httptest.NewRecorder()
writeLoadResult(rec, http.StatusOK, LoadTableResult{})
if got := rec.Header().Get("Vary"); got != accessDelegationHeader {
t.Fatalf("Vary = %q, want %q", got, accessDelegationHeader)
}
}
func TestLoadTableResultOmitsConfigForCredentialVendingClients(t *testing.T) {
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
getResp := s3tables.GetTableResponse{MetadataLocation: "s3://bkt/ns/t/metadata/v1.metadata.json"}
result, err := s.buildLoadTableResult(r, getResp, "bkt", []string{"ns"}, "t")
if err != nil {
t.Fatalf("buildLoadTableResult() error = %v", err)
}
if len(result.Config) != 0 {
t.Fatalf("LoadTableResult.Config = %v, want empty", result.Config)
}
}

View File

@@ -0,0 +1,97 @@
package iceberg
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// seedPoisonedTable registers a table entry whose stored metadataLocation
// points outside its own bucket via a ".." segment, simulating a value
// persisted verbatim by the raw S3Tables UpdateTable API.
func seedPoisonedTable(t *testing.T, fc *memFiler, bucket, namespace, tableName, metadataLocation string) {
t.Helper()
meta := s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: "00000000-0000-0000-0000-000000000001"},
}
internal := map[string]any{
"name": tableName,
"namespace": namespace,
"format": "ICEBERG",
"ownerAccountId": s3_constants.AccountAdminId,
"versionToken": "v1",
"metadataVersion": 1,
"metadataLocation": metadataLocation,
"metadata": meta,
}
metaBytes, _ := json.Marshal(internal)
fc.seed(s3tables.GetTablePath(bucket, namespace, tableName), &filer_pb.Entry{
Name: tableName,
IsDirectory: true,
Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: metaBytes},
})
}
func TestCommitTableRejectsStoredTraversalLocation(t *testing.T) {
const attacker = "attacker"
const victim = "victim"
fc := newMemFiler()
seedNamespace(fc, attacker, "ns", s3_constants.AccountAdminId)
seedPoisonedTable(t, fc, attacker, "ns", "t",
"s3://attacker/../victim/planted/metadata/v1.metadata.json")
s := NewServer(fc, nil)
r := httptest.NewRequest(http.MethodPost, "/v1/"+attacker+"/namespaces/ns/tables/t",
strings.NewReader(`{"requirements":[],"updates":[]}`))
r = mux.SetURLVars(r, map[string]string{"prefix": attacker, "namespace": "ns", "table": "t"})
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
w := httptest.NewRecorder()
s.handleUpdateTable(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body=%s)", w.Code, http.StatusBadRequest, w.Body.String())
}
for p := range fc.entries {
if strings.Contains(p, "/"+victim+"/") && !strings.Contains(p, "/"+attacker+"/") {
t.Fatalf("cross-tenant write escaped into victim tree at %s (status=%d body=%s)", p, w.Code, w.Body.String())
}
}
}
func TestCommitTransactionRejectsStoredTraversalLocation(t *testing.T) {
const attacker = "attacker"
const victim = "victim"
fc := newMemFiler()
seedNamespace(fc, attacker, "ns", s3_constants.AccountAdminId)
seedPoisonedTable(t, fc, attacker, "ns", "t",
"s3://attacker/../victim/planted/metadata/v1.metadata.json")
s := NewServer(fc, nil)
body := `{"table-changes":[{"identifier":{"namespace":["ns"],"name":"t"},"requirements":[],"updates":[]}]}`
r := httptest.NewRequest(http.MethodPost, "/v1/"+attacker+"/transactions/commit",
strings.NewReader(body))
r = mux.SetURLVars(r, map[string]string{"prefix": attacker})
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
w := httptest.NewRecorder()
s.handleCommitTransaction(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body=%s)", w.Code, http.StatusBadRequest, w.Body.String())
}
for p := range fc.entries {
if strings.Contains(p, "/"+victim+"/") && !strings.Contains(p, "/"+attacker+"/") {
t.Fatalf("cross-tenant transaction write escaped into victim tree at %s (status=%d body=%s)", p, w.Code, w.Body.String())
}
}
}

View File

@@ -0,0 +1,134 @@
package iceberg
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/apache/iceberg-go/table"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestParseCommitUpdatesSeparatesStatistics(t *testing.T) {
raw := []json.RawMessage{
json.RawMessage(`{"action":"set-statistics","snapshot-id":10,"statistics-path":"s3://bucket/table/metadata/stats.puffin","file-size-in-bytes":100,"file-footer-size-in-bytes":20,"blob-metadata":[]}`),
json.RawMessage(`{"action":"set-properties","updates":{"k":"v"}}`),
}
updates, stats, err := parseCommitUpdates(raw)
if err != nil {
t.Fatalf("parseCommitUpdates() error = %v", err)
}
if len(stats) != 1 {
t.Fatalf("statistics updates = %d, want 1", len(stats))
}
if stats[0].set == nil || stats[0].set.SnapshotID != 10 {
t.Fatalf("unexpected statistics update: %#v", stats[0])
}
if len(updates) != 1 {
t.Fatalf("decoded updates = %d, want 1", len(updates))
}
}
func TestParseCommitUpdatesRejectsIncompleteSetStatistics(t *testing.T) {
raw := []json.RawMessage{
json.RawMessage(`{"action":"set-statistics","snapshot-id":10}`),
}
_, _, err := parseCommitUpdates(raw)
if err == nil {
t.Fatalf("parseCommitUpdates() expected error")
}
if !errors.Is(err, ErrIncompleteSetStatistics) {
t.Fatalf("parseCommitUpdates() error = %v, want ErrIncompleteSetStatistics", err)
}
}
func TestApplyStatisticsUpdatesUpsertAndRemove(t *testing.T) {
metadata := []byte(`{"format-version":2,"statistics":[{"snapshot-id":1,"statistics-path":"s3://bucket/stats-1.puffin","file-size-in-bytes":10,"file-footer-size-in-bytes":1,"blob-metadata":[]},{"snapshot-id":2,"statistics-path":"s3://bucket/stats-2.puffin","file-size-in-bytes":20,"file-footer-size-in-bytes":2,"blob-metadata":[]}]} `)
snapshotID := int64(2)
setUpdates := []statisticsUpdate{
{
set: &statisticsFileForTest,
},
{
remove: &snapshotID,
},
}
updated, err := applyStatisticsUpdates(metadata, setUpdates)
if err != nil {
t.Fatalf("applyStatisticsUpdates() error = %v", err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(updated, &decoded); err != nil {
t.Fatalf("json.Unmarshal(updated) error = %v", err)
}
var stats []map[string]any
if err := json.Unmarshal(decoded["statistics"], &stats); err != nil {
t.Fatalf("json.Unmarshal(statistics) error = %v", err)
}
if len(stats) != 1 {
t.Fatalf("statistics length = %d, want 1", len(stats))
}
if got := int64(stats[0]["snapshot-id"].(float64)); got != 1 {
t.Fatalf("remaining snapshot-id = %d, want 1", got)
}
if got := int64(stats[0]["file-size-in-bytes"].(float64)); got != 11 {
t.Fatalf("remaining file-size-in-bytes = %d, want 11", got)
}
}
var statisticsFileForTest = table.StatisticsFile{
SnapshotID: 1,
StatisticsPath: "s3://bucket/stats-1.puffin",
FileSizeInBytes: 11,
FileFooterSizeInBytes: 2,
BlobMetadata: []table.BlobMetadata{},
}
func TestIsS3TablesConflict(t *testing.T) {
if !isS3TablesConflict(s3tables.ErrVersionTokenMismatch) {
t.Fatalf("expected ErrVersionTokenMismatch to be conflict")
}
if !isS3TablesConflict(&s3tables.S3TablesError{Type: s3tables.ErrCodeConflict, Message: "Version token mismatch"}) {
t.Fatalf("expected S3Tables conflict error to be conflict")
}
if isS3TablesConflict(errors.New("other")) {
t.Fatalf("unexpected conflict for non-conflict error")
}
}
// A create-on-commit is a create, and it writes the new table's metadata file
// before the registration that authorizes it.
func TestCreateOnCommitDeniedBeforeAnyWrite(t *testing.T) {
const bucket = "warehouse"
fc := newMemFiler()
seedNamespace(fc, bucket, "finance", "alice")
s := NewServer(fc, nil)
r := httptest.NewRequest(http.MethodPost, "/v1/"+bucket+"/namespaces/finance/tables/quarterly_reports",
strings.NewReader(`{"requirements":[{"type":"assert-create"}],"updates":[]}`))
r = mux.SetURLVars(r, map[string]string{"prefix": bucket, "namespace": "finance", "table": "quarterly_reports"})
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), "mallory"))
w := httptest.NewRecorder()
s.handleUpdateTable(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d (body: %s)", w.Code, http.StatusForbidden, w.Body.String())
}
for p, entry := range fc.entries {
if !entry.IsDirectory && strings.Contains(p, "quarterly_reports") {
t.Errorf("unauthorized caller wrote %s", p)
}
}
}

View File

@@ -0,0 +1,200 @@
package iceberg
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/apache/iceberg-go"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestValidateCreateTableRequestRequiresName(t *testing.T) {
err := validateCreateTableRequest(CreateTableRequest{})
if !errors.Is(err, errTableNameRequired) {
t.Fatalf("validateCreateTableRequest() error = %v, want errTableNameRequired", err)
}
}
func TestValidateCreateTableRequestAcceptsWithName(t *testing.T) {
err := validateCreateTableRequest(CreateTableRequest{Name: "orders"})
if err != nil {
t.Fatalf("validateCreateTableRequest() error = %v, want nil", err)
}
}
func TestIsStageCreateEnabledDefaultsToTrue(t *testing.T) {
t.Setenv("ICEBERG_ENABLE_STAGE_CREATE", "")
if !isStageCreateEnabled() {
t.Fatalf("isStageCreateEnabled() = false, want true")
}
}
func TestIsStageCreateEnabledFalseValues(t *testing.T) {
falseValues := []string{"0", "false", "FALSE", "no", "off"}
for _, value := range falseValues {
t.Setenv("ICEBERG_ENABLE_STAGE_CREATE", value)
if isStageCreateEnabled() {
t.Fatalf("isStageCreateEnabled() = true for value %q, want false", value)
}
}
}
func mustParseSchema(t *testing.T, raw string) *iceberg.Schema {
t.Helper()
var schema iceberg.Schema
if err := json.Unmarshal([]byte(raw), &schema); err != nil {
t.Fatalf("parse schema: %v", err)
}
return &schema
}
const variantSchema = `{"type":"struct","schema-id":0,"fields":[
{"id":1,"name":"id","required":true,"type":"long"},
{"id":2,"name":"payload","required":false,"type":"variant"}]}`
// A v3-only column type without format-version 3 is the client's mistake, and
// the reason has to travel back to them rather than only into the server log.
func TestNewTableMetadataRejectsV3TypeBelowV3(t *testing.T) {
_, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", mustParseSchema(t, variantSchema), nil, nil, nil)
if err == nil {
t.Fatal("newTableMetadata() error = nil, want invalid schema")
}
if !errors.Is(err, iceberg.ErrInvalidSchema) {
t.Fatalf("newTableMetadata() error = %v, want ErrInvalidSchema", err)
}
// writeManagerError turns this into a 400; see TestWriteManagerError.
if !strings.Contains(err.Error(), "variant is not supported until v3") {
t.Errorf("error = %q, want the underlying reason", err.Error())
}
}
func TestNewTableMetadataAcceptsV3TypeAtV3(t *testing.T) {
metadata, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", mustParseSchema(t, variantSchema), nil, nil,
iceberg.Properties{"format-version": "3"})
if err != nil {
t.Fatalf("newTableMetadata() error = %v, want nil", err)
}
if got := metadata.Version(); got != 3 {
t.Fatalf("metadata.Version() = %d, want 3", got)
}
if _, found := metadata.CurrentSchema().FindFieldByName("payload"); !found {
t.Error("variant field missing from stored schema")
}
}
// A LoadTable response must never carry nil metadata: it serializes as
// "metadata":null under HTTP 200, which no Iceberg client can parse.
func TestBuildLoadTableResultNeverReturnsNilMetadata(t *testing.T) {
cases := map[string]s3tables.GetTableResponse{
"no stored metadata": {MetadataLocation: "s3://bkt/ns/t/metadata/v1.metadata.json"},
"empty full metadata": {Metadata: &s3tables.TableMetadata{}},
"unparseable metadata": {Metadata: &s3tables.TableMetadata{FullMetadata: json.RawMessage(`{"nope":`)}},
}
for name, getResp := range cases {
t.Run(name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
result, err := (&Server{}).buildLoadTableResult(r, getResp, "bkt", []string{"ns"}, "t")
if err != nil {
t.Fatalf("buildLoadTableResult() error = %v, want nil", err)
}
if result.Metadata == nil {
t.Fatal("buildLoadTableResult() returned nil metadata with no error")
}
})
}
}
func newCreateTableRequest(t *testing.T, bucket, namespace, body, identity string) *http.Request {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/v1/"+bucket+"/namespaces/"+namespace+"/tables", strings.NewReader(body))
r = mux.SetURLVars(r, map[string]string{"prefix": bucket, "namespace": namespace})
return r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), identity))
}
// A caller who may not create the table must be refused before anything is
// written: both branches of CreateTable write to the bucket, and stage-create
// never reaches the registration that carries the authorization.
func TestCreateTableDeniedBeforeAnyWrite(t *testing.T) {
const bucket = "warehouse"
for name, body := range map[string]string{
"stage-create": `{"name":"quarterly_reports","stage-create":true}`,
"create": `{"name":"quarterly_reports"}`,
} {
t.Run(name, func(t *testing.T) {
fc := newMemFiler()
seedNamespace(fc, bucket, "finance", "alice")
s := NewServer(fc, nil)
w := httptest.NewRecorder()
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", body, "mallory"))
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d (body: %s)", w.Code, http.StatusForbidden, w.Body.String())
}
for p, entry := range fc.entries {
if !entry.IsDirectory && strings.Contains(p, "quarterly_reports") {
t.Errorf("unauthorized caller wrote %s", p)
}
}
})
}
}
// The namespace owner still gets the staged metadata and marker stage-create
// exists to leave behind.
func TestStageCreateWritesStagedFilesForOwner(t *testing.T) {
const bucket = "warehouse"
fc := newMemFiler()
seedNamespace(fc, bucket, "finance", "alice")
s := NewServer(fc, nil)
w := httptest.NewRecorder()
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", `{"name":"quarterly_reports","stage-create":true}`, "alice"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
}
staged := 0
for p, entry := range fc.entries {
if !entry.IsDirectory && strings.Contains(p, stageCreateMarkerDirName) {
staged++
}
}
if staged != 2 {
t.Fatalf("staged files = %d, want the metadata file and its marker", staged)
}
if _, ok := fc.entries[s3tables.GetTablePath(bucket, "finance", "quarterly_reports")]; ok {
t.Error("stage-create registered the table")
}
}
// A caller allowed by the actions on its own identity, rather than by ownership
// or a resource policy, has to survive the hop into the s3tables manager: the
// gate must not refuse what the create behind it would allow.
func TestCreateTableAllowedByIdentityActions(t *testing.T) {
const bucket = "warehouse"
fc := newMemFiler()
seedNamespace(fc, bucket, "finance", "alice")
s := NewServer(fc, nil)
r := newCreateTableRequest(t, bucket, "finance", `{"name":"quarterly_reports","stage-create":true}`, "mallory")
r = r.WithContext(s3_constants.SetIdentityInContext(r.Context(), &struct {
Name string
Actions []string
}{Name: "mallory", Actions: []string{"s3tables:CreateTable"}}))
w := httptest.NewRecorder()
s.handleCreateTable(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
}
}

View File

@@ -0,0 +1,233 @@
package iceberg
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"path"
"sort"
"strings"
"testing"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/apache/iceberg-go/view"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"google.golang.org/grpc"
)
// memFiler is a minimal in-memory SeaweedFilerClient backing the view handlers
// end-to-end through the s3tables manager. Only the entry operations the create
// path touches are implemented; anything else panics so a missing dependency is
// loud rather than silently passing.
type memFiler struct {
filer_pb.SeaweedFilerClient
entries map[string]*filer_pb.Entry
// failFileCreate, when set, makes CreateEntry fail for non-directory entries,
// simulating a metadata-file write error.
failFileCreate error
}
func newMemFiler() *memFiler {
return &memFiler{entries: map[string]*filer_pb.Entry{}}
}
func (m *memFiler) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
return fn(m)
}
func (m *memFiler) seed(p string, entry *filer_pb.Entry) {
m.entries[p] = entry
}
func (m *memFiler) LookupDirectoryEntry(_ context.Context, in *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
entry, ok := m.entries[path.Join(in.Directory, in.Name)]
if !ok {
return nil, filer_pb.ErrNotFound
}
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
}
func (m *memFiler) CreateEntry(_ context.Context, in *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
if m.failFileCreate != nil && !in.Entry.IsDirectory {
return nil, m.failFileCreate
}
m.entries[path.Join(in.Directory, in.Entry.Name)] = in.Entry
return &filer_pb.CreateEntryResponse{}, nil
}
func (m *memFiler) DeleteEntry(_ context.Context, in *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
prefix := path.Join(in.Directory, in.Name)
for p := range m.entries {
if p == prefix || strings.HasPrefix(p, prefix+"/") {
delete(m.entries, p)
}
}
return &filer_pb.DeleteEntryResponse{}, nil
}
func (m *memFiler) UpdateEntry(_ context.Context, in *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
m.entries[path.Join(in.Directory, in.Entry.Name)] = in.Entry
return &filer_pb.UpdateEntryResponse{}, nil
}
func (m *memFiler) ListEntries(_ context.Context, in *filer_pb.ListEntriesRequest, _ ...grpc.CallOption) (filer_pb.SeaweedFiler_ListEntriesClient, error) {
var names []string
for p := range m.entries {
if path.Dir(p) == in.Directory {
names = append(names, p)
}
}
sort.Strings(names)
stream := &memListEntries{}
for _, name := range names {
stream.entries = append(stream.entries, m.entries[name])
}
return stream, nil
}
type memListEntries struct {
grpc.ClientStream
entries []*filer_pb.Entry
}
func (m *memListEntries) Recv() (*filer_pb.ListEntriesResponse, error) {
if len(m.entries) == 0 {
return nil, io.EOF
}
entry := m.entries[0]
m.entries = m.entries[1:]
return &filer_pb.ListEntriesResponse{Entry: entry}, nil
}
func newCreateViewRequest(t *testing.T, namespace, name, sql string) *http.Request {
t.Helper()
schema := iceberg.NewSchemaWithIdentifiers(0, nil,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int32, Required: true},
)
ver, err := view.NewVersionFromSQL(1, schema.ID, sql, table.Identifier{namespace})
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(CreateViewRequest{Name: name, Schema: schema, ViewVersion: ver})
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/"+namespace+"/views", bytes.NewReader(body))
r = mux.SetURLVars(r, map[string]string{"namespace": namespace})
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
return r
}
// seedNamespace registers a bucket and namespace owned by owner so the s3tables
// existence and auth-context lookups pass.
func seedNamespace(fc *memFiler, bucket, namespace, owner string) {
fc.seed(s3tables.GetTableBucketPath(bucket), &filer_pb.Entry{Name: bucket, IsDirectory: true})
meta, _ := json.Marshal(map[string]any{"namespace": []string{namespace}, "ownerAccountId": owner})
fc.seed(s3tables.GetNamespacePath(bucket, namespace), &filer_pb.Entry{
Name: namespace,
IsDirectory: true,
Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: meta},
})
}
func TestCreateViewMissingNamespaceReturns404(t *testing.T) {
fc := newMemFiler()
s := NewServer(fc, nil)
w := httptest.NewRecorder()
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusNotFound, w.Body.String())
}
var errResp ErrorResponse
if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil {
t.Fatalf("unmarshal error body: %v", err)
}
if errResp.Error.Type != "NoSuchNamespaceException" {
t.Fatalf("error type = %q, want NoSuchNamespaceException", errResp.Error.Type)
}
}
func TestCreateViewTagsEntryAsView(t *testing.T) {
const bucket = "warehouse"
fc := newMemFiler()
seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId)
s := NewServer(fc, nil)
w := httptest.NewRecorder()
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
if w.Code != http.StatusOK {
t.Fatalf("create status = %d, want 200 (body: %s)", w.Code, w.Body.String())
}
entry, ok := fc.entries[s3tables.GetTablePath(bucket, "ns", "v")]
if !ok {
t.Fatalf("view entry not created")
}
if got := string(entry.Extended[s3tables.ExtendedKeyEntryType]); got != s3tables.EntryTypeView {
t.Fatalf("entryType = %q, want %q", got, s3tables.EntryTypeView)
}
if _, ok := entry.Extended[s3tables.ExtendedKeyMetadata]; !ok {
t.Fatalf("view entry missing metadata attribute")
}
}
func TestCreateViewDuplicateDoesNotClobberMetadata(t *testing.T) {
const bucket = "warehouse"
fc := newMemFiler()
seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId)
s := NewServer(fc, nil)
w := httptest.NewRecorder()
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
if w.Code != http.StatusOK {
t.Fatalf("first create status = %d, want 200 (body: %s)", w.Code, w.Body.String())
}
metadataKey := path.Join(s3tables.GetTablePath(bucket, "ns", "v"), "metadata", "v1.metadata.json")
first, ok := fc.entries[metadataKey]
if !ok {
t.Fatalf("metadata file not written at %s", metadataKey)
}
original := append([]byte(nil), first.Content...)
// Re-create the same view with different SQL: the existence pre-check must
// short-circuit so the persisted v1.metadata.json stays byte-for-byte intact.
w = httptest.NewRecorder()
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 2"))
if w.Code != http.StatusOK {
t.Fatalf("duplicate create status = %d, want 200 (body: %s)", w.Code, w.Body.String())
}
if got := fc.entries[metadataKey].Content; !bytes.Equal(got, original) {
t.Fatalf("duplicate create clobbered stored metadata:\n got %s\nwant %s", got, original)
}
}
func TestCreateViewRollsBackEntryWhenMetadataWriteFails(t *testing.T) {
const bucket = "warehouse"
fc := newMemFiler()
seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId)
fc.failFileCreate = errors.New("disk full")
s := NewServer(fc, nil)
w := httptest.NewRecorder()
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusInternalServerError, w.Body.String())
}
// The registered view must be rolled back so it doesn't linger pointing at
// metadata that was never written.
if _, ok := fc.entries[s3tables.GetTablePath(bucket, "ns", "v")]; ok {
t.Fatalf("view entry left behind after metadata write failure")
}
}

View File

@@ -0,0 +1,111 @@
package iceberg
import (
"context"
"errors"
"net/http/httptest"
"testing"
"time"
)
type stubVendor struct {
credentials *VendedCredentials
err error
gotBucket string
gotPrefix string
gotcaller string
}
func (v *stubVendor) VendTableCredentials(_ context.Context, principal, bucket, prefix string) (*VendedCredentials, error) {
v.gotcaller, v.gotBucket, v.gotPrefix = principal, bucket, prefix
return v.credentials, v.err
}
func vendingServer(vendor CredentialVendor) *Server {
s := &Server{s3Endpoint: "http://s3.example:8333"}
s.SetCredentialVendor(vendor)
return s
}
func TestBuildFileIOConfigVendingVendsScopedCredentials(t *testing.T) {
expiry := time.Now().Add(time.Hour).Truncate(time.Millisecond)
vendor := &stubVendor{credentials: &VendedCredentials{
AccessKeyID: "ASIAEXAMPLE",
SecretAccessKey: "secret",
SessionToken: "token",
Expiration: expiry,
}}
s := vendingServer(vendor)
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if vendor.gotBucket != "warehouse" || vendor.gotPrefix != "ns/t" {
t.Errorf("vendor called with bucket=%q prefix=%q, want warehouse and ns/t", vendor.gotBucket, vendor.gotPrefix)
}
if config["s3.access-key-id"] != "ASIAEXAMPLE" || config["s3.secret-access-key"] != "secret" || config["s3.session-token"] != "token" {
t.Errorf("config missing vended credentials: %v", config)
}
if config["s3.endpoint"] != "http://s3.example:8333" {
t.Errorf("config missing the endpoint the credentials are for: %v", config)
}
if len(storageCredentials) != 1 {
t.Fatalf("storage-credentials = %d entries, want 1", len(storageCredentials))
}
if storageCredentials[0].Prefix != "s3://warehouse/ns/t" {
t.Errorf("storage credential prefix = %q", storageCredentials[0].Prefix)
}
if storageCredentials[0].Config["s3.session-token"] != "token" {
t.Errorf("storage credential is missing the session token: %v", storageCredentials[0].Config)
}
}
// Without a vendor the response must stay silent about the endpoint too: a
// client that asked for vended credentials would otherwise drop its own and
// start sending unsigned requests.
func TestBuildFileIOConfigVendingWithoutVendorSaysNothing(t *testing.T) {
s := &Server{s3Endpoint: "http://s3.example:8333"}
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if len(config) != 0 || storageCredentials != nil {
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
}
}
func TestBuildFileIOConfigVendingFallsBackWhenVendingFails(t *testing.T) {
s := vendingServer(&stubVendor{err: errors.New("no role configured")})
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if len(config) != 0 || storageCredentials != nil {
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
}
}
// A client that did not ask for delegation keeps the plain endpoint config and
// must never be handed credentials.
func TestBuildFileIOConfigVendingWithoutDelegationHeader(t *testing.T) {
vendor := &stubVendor{credentials: &VendedCredentials{AccessKeyID: "ASIAEXAMPLE"}}
s := vendingServer(vendor)
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if _, vended := config["s3.access-key-id"]; vended {
t.Errorf("credentials vended without the delegation header: %v", config)
}
if config["s3.endpoint"] != "http://s3.example:8333" {
t.Errorf("config = %v, want the endpoint", config)
}
if storageCredentials != nil {
t.Errorf("storage-credentials = %v, want none", storageCredentials)
}
if vendor.gotBucket != "" {
t.Errorf("vendor was called for a request that did not ask for delegation")
}
}

View File

@@ -0,0 +1,87 @@
package iceberg
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/apache/iceberg-go"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestNameValidationError(t *testing.T) {
cases := []struct {
err error
want bool
}{
{nil, false},
// Wrapped exactly as the s3tables manager surfaces it.
{fmt.Errorf("all filers failed, last error: invalid namespace name: only 'a-z', '0-9', and '_' are allowed"), true},
{fmt.Errorf("invalid table name: only 'a-z', '0-9', and '_' are allowed"), true},
{fmt.Errorf("namespace name must start with a letter or digit"), true},
{fmt.Errorf("namespace name cannot start with reserved prefix 'aws'"), true},
{fmt.Errorf("table name must be between 1 and 255 characters"), true},
{fmt.Errorf("namespace not found"), false},
{fmt.Errorf("all filers failed, last error: rpc timeout"), false},
// Unrelated faults that merely mention a name must stay 500.
{fmt.Errorf("failed to resolve table name from index"), false},
{fmt.Errorf("error fetching namespace name mapping"), false},
}
for _, c := range cases {
if got := nameValidationError(c.err); got != c.want {
t.Errorf("nameValidationError(%v) = %v, want %v", c.err, got, c.want)
}
}
}
func TestWriteManagerError(t *testing.T) {
cases := []struct {
name string
err error
wantCode int
wantType string
wantMessage []string
}{
{name: "invalid name is a client error", err: fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed"), wantCode: http.StatusBadRequest, wantType: "BadRequestException"},
{name: "rejected schema is a client error", err: fmt.Errorf("%w: for v2: variant is not supported until v3", iceberg.ErrInvalidSchema), wantCode: http.StatusBadRequest, wantType: "BadRequestException"},
{name: "bad format version is a client error", err: fmt.Errorf("%w: 4", iceberg.ErrInvalidFormatVersion), wantCode: http.StatusBadRequest, wantType: "BadRequestException"},
{
name: "missing table bucket is a client error",
err: fmt.Errorf("all filers failed, last error: %w", &s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchBucket, Message: "table bucket warehouse not found"}),
wantCode: http.StatusNotFound,
wantType: "NoSuchNamespaceException",
// The guidance is the point of the mapping: the bucket the request
// resolved to is one the client never named, so the response has to
// say how to name a real one.
wantMessage: []string{"table bucket warehouse not found", "warehouse=s3://<table-bucket>/", "/v1/<table-bucket>/"},
},
{name: "everything else is a server fault", err: fmt.Errorf("all filers failed, last error: connection refused"), wantCode: http.StatusInternalServerError, wantType: "InternalServerError"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
writeManagerError(rec, c.err)
if rec.Code != c.wantCode {
t.Fatalf("status = %d, want %d", rec.Code, c.wantCode)
}
var resp ErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Error.Type != c.wantType {
t.Fatalf("error type = %q, want %q", resp.Error.Type, c.wantType)
}
if resp.Error.Code != c.wantCode {
t.Fatalf("error code = %d, want %d", resp.Error.Code, c.wantCode)
}
for _, want := range c.wantMessage {
if !strings.Contains(resp.Error.Message, want) {
t.Errorf("message %q does not contain %q", resp.Error.Message, want)
}
}
})
}
}

View File

@@ -0,0 +1,105 @@
package iceberg
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGetBucketFromPrefix_WarehouseQueryFallback(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "warehouse query routes to its bucket when no prefix in path",
url: "/v1/namespaces?warehouse=s3%3A%2F%2Fmyblkt%2F",
want: "myblkt",
},
{
name: "warehouse query with sub-path still picks the bucket",
url: "/v1/namespaces?warehouse=s3%3A%2F%2Fanother%2Fextra",
want: "another",
},
{
name: "bare bucket name is taken as the table bucket",
url: "/v1/namespaces?warehouse=not-a-url",
want: "not-a-url",
},
{
name: "table bucket ARN routes to its bucket",
url: "/v1/namespaces?warehouse=arn%3Aaws%3As3tables%3Aus-east-1%3Aadmin%3Abucket%2Fseaweed-iceberg",
want: "seaweed-iceberg",
},
{
name: "unusable warehouse value falls through to default",
url: "/v1/namespaces?warehouse=file%3A%2F%2F%2Ftmp%2Fwh",
want: "warehouse",
},
{
name: "no warehouse and no prefix returns default",
url: "/v1/namespaces",
want: "warehouse",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", tc.url, nil)
got := getBucketFromPrefix(r)
if got != tc.want {
t.Fatalf("getBucketFromPrefix(%q) = %q, want %q", tc.url, got, tc.want)
}
})
}
}
func TestBuildFileIOConfig(t *testing.T) {
loadTable := func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
}
t.Run("no endpoint configured yields empty config", func(t *testing.T) {
s := &Server{}
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t")
if len(got) != 0 {
t.Fatalf("buildFileIOConfig() = %v, want empty", got)
}
})
t.Run("endpoint is advertised with path-style-access and region", func(t *testing.T) {
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t")
if got["s3.endpoint"] != "http://seaweed.example:8333" {
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], "http://seaweed.example:8333")
}
if got["s3.path-style-access"] != "true" {
t.Fatalf("s3.path-style-access = %q, want %q", got["s3.path-style-access"], "true")
}
if got["s3.region"] == "" {
t.Fatalf("s3.region was empty, want a non-empty default so clients like DuckDB do not require AWS_REGION")
}
})
}
func TestGetBucketFromPrefix_TableBucketEnvFallback(t *testing.T) {
r := httptest.NewRequest("GET", "/v1/namespaces", nil)
t.Setenv("S3_TABLE_BUCKET", " ,analytics, other")
if got := getBucketFromPrefix(r); got != "analytics" {
t.Fatalf("first S3_TABLE_BUCKET entry: got %q, want analytics", got)
}
// The explicit default still wins over the table bucket list.
t.Setenv("S3TABLES_DEFAULT_BUCKET", "explicit")
if got := getBucketFromPrefix(r); got != "explicit" {
t.Fatalf("S3TABLES_DEFAULT_BUCKET override: got %q, want explicit", got)
}
// A prefixless value with neither env set falls through to the default.
t.Setenv("S3TABLES_DEFAULT_BUCKET", "")
t.Setenv("S3_TABLE_BUCKET", " , ")
if got := getBucketFromPrefix(r); got != "warehouse" {
t.Fatalf("empty specs: got %q, want warehouse", got)
}
}

View File

@@ -0,0 +1,29 @@
package iceberg
import "testing"
func TestNormalizeNamespacePropertiesNil(t *testing.T) {
properties := normalizeNamespaceProperties(nil)
if properties == nil {
t.Fatalf("normalizeNamespaceProperties(nil) returned nil map")
}
if len(properties) != 0 {
t.Fatalf("normalizeNamespaceProperties(nil) length = %d, want 0", len(properties))
}
}
func TestNormalizeNamespacePropertiesReturnsInputWhenSet(t *testing.T) {
input := map[string]string{
"owner": "analytics",
}
properties := normalizeNamespaceProperties(input)
if properties["owner"] != "analytics" {
t.Fatalf("normalized properties value = %q, want %q", properties["owner"], "analytics")
}
input["owner"] = "updated"
if properties["owner"] != "updated" {
t.Fatalf("normalizeNamespaceProperties should reuse the input map when non-nil")
}
}

View File

@@ -0,0 +1,69 @@
package iceberg
import (
"net/http/httptest"
"testing"
)
func TestParsePaginationDefaultValues(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/namespaces", nil)
pageToken, pageSize, err := parsePagination(req)
if err != nil {
t.Fatalf("parsePagination() error = %v", err)
}
if pageToken != "" {
t.Fatalf("pageToken = %q, want empty", pageToken)
}
if pageSize != defaultListPageSize {
t.Fatalf("pageSize = %d, want %d", pageSize, defaultListPageSize)
}
}
func TestParsePaginationUsesCamelCaseParameters(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/namespaces?pageToken=abc&pageSize=25", nil)
pageToken, pageSize, err := parsePagination(req)
if err != nil {
t.Fatalf("parsePagination() error = %v", err)
}
if pageToken != "abc" {
t.Fatalf("pageToken = %q, want %q", pageToken, "abc")
}
if pageSize != 25 {
t.Fatalf("pageSize = %d, want %d", pageSize, 25)
}
}
func TestParsePaginationSupportsHyphenatedFallback(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/namespaces?page-token=abc&page-size=17", nil)
pageToken, pageSize, err := parsePagination(req)
if err != nil {
t.Fatalf("parsePagination() error = %v", err)
}
if pageToken != "abc" {
t.Fatalf("pageToken = %q, want %q", pageToken, "abc")
}
if pageSize != 17 {
t.Fatalf("pageSize = %d, want %d", pageSize, 17)
}
}
func TestParsePaginationRejectsInvalidPageSize(t *testing.T) {
testCases := []string{
"/v1/namespaces?pageSize=0",
"/v1/namespaces?pageSize=-1",
"/v1/namespaces?pageSize=foo",
"/v1/namespaces?pageSize=1001",
}
for _, rawURL := range testCases {
t.Run(rawURL, func(t *testing.T) {
req := httptest.NewRequest("GET", rawURL, nil)
if _, _, err := parsePagination(req); err == nil {
t.Fatalf("parsePagination() expected error for url %q", rawURL)
}
})
}
}

View File

@@ -0,0 +1,61 @@
package iceberg
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
)
// TestRegisterTableRequestWireShape pins the field names to the iceberg-go REST
// client payload: {"name", "metadata-location"}.
func TestRegisterTableRequestWireShape(t *testing.T) {
body := `{"name":"orders","metadata-location":"s3://bucket/ns/orders/metadata/v1.metadata.json"}`
var req RegisterTableRequest
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if req.Name != "orders" {
t.Errorf("Name = %q, want orders", req.Name)
}
if req.MetadataLocation != "s3://bucket/ns/orders/metadata/v1.metadata.json" {
t.Errorf("MetadataLocation = %q", req.MetadataLocation)
}
}
// TestRegisterTableRejectsOutOfBoundsLocation confirms a metadata-location that
// points outside the request's table bucket, or contains a traversal segment, is
// rejected with 400 before any metadata read.
func TestRegisterTableRejectsOutOfBoundsLocation(t *testing.T) {
cases := []struct {
name string
metadataLocation string
}{
{"cross bucket", "s3://other/ns/orders/metadata/v1.metadata.json"},
{"traversal", "s3://bucket/ns/../../etc/metadata/v1.metadata.json"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
body := `{"name":"orders","metadata-location":"` + c.metadataLocation + `"}`
r := httptest.NewRequest(http.MethodPost, "/v1/bucket/namespaces/ns/register", strings.NewReader(body))
r = mux.SetURLVars(r, map[string]string{"prefix": "bucket", "namespace": "ns"})
rec := httptest.NewRecorder()
(&Server{}).handleRegisterTable(rec, r)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var resp ErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Error.Type != "BadRequestException" {
t.Fatalf("error type = %q, want BadRequestException", resp.Error.Type)
}
})
}
}

View File

@@ -0,0 +1,32 @@
package iceberg
import (
"net/http/httptest"
"strings"
"testing"
)
func TestHandleRenameTableRejectsIncompleteIdentifiers(t *testing.T) {
cases := []struct {
name string
body string
}{
{"missing source name", `{"source":{"namespace":["ns"]},"destination":{"namespace":["ns"],"name":"t2"}}`},
{"missing source namespace", `{"source":{"name":"t"},"destination":{"namespace":["ns"],"name":"t2"}}`},
{"missing destination name", `{"source":{"namespace":["ns"],"name":"t"},"destination":{"namespace":["ns"]}}`},
{"missing destination namespace", `{"source":{"namespace":["ns"],"name":"t"},"destination":{"name":"t2"}}`},
{"empty body", `{}`},
{"malformed json", `{`},
}
s := &Server{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/v1/tables/rename", strings.NewReader(tc.body))
w := httptest.NewRecorder()
s.handleRenameTable(w, r)
if w.Code != 400 {
t.Fatalf("handleRenameTable() status = %d, want 400", w.Code)
}
})
}
}

View File

@@ -0,0 +1,93 @@
package iceberg
import (
"net/http/httptest"
"testing"
"time"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
)
// metadataWithSnapshots builds metadata whose main branch is on the last
// snapshot, optionally tagging one of the earlier ones.
func metadataWithSnapshots(t *testing.T, ids []int64, tagged int64) table.Metadata {
t.Helper()
base, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", nil, nil, nil, nil)
if err != nil {
t.Fatalf("newTableMetadata() error = %v", err)
}
builder, err := table.MetadataBuilderFromBase(base, "")
if err != nil {
t.Fatalf("MetadataBuilderFromBase() error = %v", err)
}
now := time.Now().UnixMilli()
for i, id := range ids {
snapshot := table.Snapshot{
SnapshotID: id,
TimestampMs: now + int64(i),
ManifestList: "metadata/snap-" + string(rune('0'+i)) + ".avro",
}
if err := builder.AddSnapshot(&snapshot); err != nil {
t.Fatalf("AddSnapshot(%d) error = %v", id, err)
}
}
if err := builder.SetSnapshotRef(table.MainBranch, ids[len(ids)-1], table.BranchRef); err != nil {
t.Fatalf("SetSnapshotRef(main) error = %v", err)
}
if tagged != 0 {
if err := builder.SetSnapshotRef("release", tagged, table.TagRef); err != nil {
t.Fatalf("SetSnapshotRef(release) error = %v", err)
}
}
metadata, err := builder.Build()
if err != nil {
t.Fatalf("Build() error = %v", err)
}
return metadata
}
func snapshotIDs(metadata table.Metadata) map[int64]bool {
ids := map[int64]bool{}
for _, snapshot := range metadata.Snapshots() {
ids[snapshot.SnapshotID] = true
}
return ids
}
func TestApplySnapshotsParamRefsKeepsOnlyReferenced(t *testing.T) {
metadata := metadataWithSnapshots(t, []int64{1, 2, 3}, 1)
got, err := applySnapshotsParam(httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t?snapshots=refs", nil), metadata)
if err != nil {
t.Fatalf("applySnapshotsParam() error = %v", err)
}
ids := snapshotIDs(got)
if !ids[1] {
t.Error("tagged snapshot 1 was dropped")
}
if !ids[3] {
t.Error("current snapshot 3 was dropped")
}
if ids[2] {
t.Error("unreferenced snapshot 2 was returned")
}
}
func TestApplySnapshotsParamDefaultsToAll(t *testing.T) {
metadata := metadataWithSnapshots(t, []int64{1, 2, 3}, 1)
for _, target := range []string{"/v1/namespaces/ns/tables/t", "/v1/namespaces/ns/tables/t?snapshots=all"} {
got, err := applySnapshotsParam(httptest.NewRequest("GET", target, nil), metadata)
if err != nil {
t.Fatalf("applySnapshotsParam(%s) error = %v", target, err)
}
if len(got.Snapshots()) != 3 {
t.Errorf("applySnapshotsParam(%s) kept %d snapshots, want 3", target, len(got.Snapshots()))
}
}
}

View File

@@ -0,0 +1,254 @@
package iceberg
import (
"bytes"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestDecodeCommitTransactionRequest(t *testing.T) {
body := `{"table-changes":[
{"identifier":{"namespace":["ns"],"name":"t1"},"requirements":[{"type":"assert-table-uuid","uuid":"00000000-0000-0000-0000-000000000001"}],"updates":[{"action":"set-properties","updates":{"k":"v"}}]},
{"identifier":{"namespace":["ns","sub"],"name":"t2"},"requirements":[],"updates":[]}
]}`
var req CommitTransactionRequest
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(req.TableChanges) != 2 {
t.Fatalf("table-changes = %d, want 2", len(req.TableChanges))
}
if req.TableChanges[0].Identifier.Name != "t1" {
t.Fatalf("change[0] name = %q, want t1", req.TableChanges[0].Identifier.Name)
}
if got := []string(req.TableChanges[1].Identifier.Namespace); len(got) != 2 || got[1] != "sub" {
t.Fatalf("change[1] namespace = %v, want [ns sub]", got)
}
}
func TestParseTableChangeSeparatesStatistics(t *testing.T) {
change := tableChangeRequest{
Requirements: json.RawMessage(`[{"type":"assert-table-uuid","uuid":"00000000-0000-0000-0000-000000000001"}]`),
Updates: []json.RawMessage{
json.RawMessage(`{"action":"set-properties","updates":{"k":"v"}}`),
json.RawMessage(`{"action":"set-statistics","snapshot-id":10,"statistics-path":"s3://bucket/table/metadata/stats.puffin","file-size-in-bytes":100,"file-footer-size-in-bytes":20,"blob-metadata":[]}`),
},
}
requirements, updates, statistics, reqErr := parseTableChange(change)
if reqErr != nil {
t.Fatalf("parseTableChange() error = %+v", reqErr)
}
if len(requirements) != 1 {
t.Fatalf("requirements = %d, want 1", len(requirements))
}
if len(updates) != 1 {
t.Fatalf("updates = %d, want 1", len(updates))
}
if len(statistics) != 1 || statistics[0].set == nil || statistics[0].set.SnapshotID != 10 {
t.Fatalf("unexpected statistics updates: %#v", statistics)
}
}
func TestParseTableChangeRejectsInvalidRequirements(t *testing.T) {
change := tableChangeRequest{Requirements: json.RawMessage(`{not json}`)}
_, _, _, reqErr := parseTableChange(change)
if reqErr == nil {
t.Fatalf("parseTableChange() expected error for invalid requirements")
}
if reqErr.status != 400 {
t.Fatalf("status = %d, want 400", reqErr.status)
}
}
func TestCloneTableMetadataIsIndependent(t *testing.T) {
orig := &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: "uuid-1"},
FullMetadata: json.RawMessage(`{"v":1}`),
}
clone := cloneTableMetadata(orig)
if clone == orig || clone.Iceberg == orig.Iceberg {
t.Fatalf("cloneTableMetadata returned aliased pointers")
}
orig.Iceberg.TableUUID = "mutated"
orig.FullMetadata[0] = 'X'
if clone.Iceberg.TableUUID != "uuid-1" {
t.Fatalf("clone Iceberg.TableUUID = %q, want uuid-1", clone.Iceberg.TableUUID)
}
if string(clone.FullMetadata) != `{"v":1}` {
t.Fatalf("clone FullMetadata = %q, mutated with source", clone.FullMetadata)
}
if cloneTableMetadata(nil) != nil {
t.Fatalf("cloneTableMetadata(nil) = non-nil")
}
}
func TestBuildTableRestoreRequestCarriesPriorState(t *testing.T) {
pc := &preparedTableCommit{
namespace: []string{"ns", "sub"},
tableName: "t1",
prevMetadataLoc: "s3://bucket/ns/t1/metadata/v3.metadata.json",
prevMetadataVersion: 3,
prevMetadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: uuid.NewString()},
FullMetadata: json.RawMessage(`{"format-version":2,"last-updated-ms":3}`),
},
}
req := buildTableRestoreRequest("arn:bucket", pc)
if req.TableBucketARN != "arn:bucket" || req.Name != "t1" {
t.Fatalf("restore target = %s/%s, want arn:bucket/t1", req.TableBucketARN, req.Name)
}
if req.MetadataLocation != pc.prevMetadataLoc {
t.Fatalf("restore MetadataLocation = %q, want %q", req.MetadataLocation, pc.prevMetadataLoc)
}
if req.MetadataVersion != pc.prevMetadataVersion {
t.Fatalf("restore MetadataVersion = %d, want %d", req.MetadataVersion, pc.prevMetadataVersion)
}
if req.Metadata == nil || !bytes.Equal(req.Metadata.FullMetadata, pc.prevMetadata.FullMetadata) {
t.Fatalf("restore FullMetadata = %q, want %q", req.Metadata.FullMetadata, pc.prevMetadata.FullMetadata)
}
if req.Metadata.Iceberg == nil || req.Metadata.Iceberg.TableUUID != pc.prevMetadata.Iceberg.TableUUID {
t.Fatalf("restore TableUUID mismatch")
}
}
// TestRollbackRevertsEveryFlippedField mirrors handleUpdateTable's partial-field
// update semantics to prove that after a flip + rollback the table is back to its
// pre-transaction state. With the old location-only rollback the table kept the
// new full metadata and version; the restore request must revert all of them.
func TestRollbackRevertsEveryFlippedField(t *testing.T) {
// prior on-disk state of an already-committed table.
prior := tableEntry{
metadataLocation: "s3://bucket/ns/t1/metadata/v3.metadata.json",
metadataVersion: 3,
metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: "uuid-1"},
FullMetadata: json.RawMessage(`{"format-version":2,"v":3}`),
},
}
pc := &preparedTableCommit{
namespace: []string{"ns"},
tableName: "t1",
tableUUID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
metadataBytes: json.RawMessage(`{"format-version":2,"v":4}`),
metadataVersion: 4,
newMetadataLoc: "s3://bucket/ns/t1/metadata/v4.metadata.json",
prevMetadataLoc: prior.metadataLocation,
prevMetadataVersion: prior.metadataVersion,
prevMetadata: cloneTableMetadata(prior.metadata),
}
// flip: apply the new commit, then roll it back via the restore request.
state := prior.clone()
state.applyUpdate(&s3tables.UpdateTableRequest{
Metadata: &s3tables.TableMetadata{Iceberg: &s3tables.IcebergMetadata{TableUUID: pc.tableUUID.String()}, FullMetadata: pc.metadataBytes},
MetadataVersion: pc.metadataVersion,
MetadataLocation: pc.newMetadataLoc,
})
state.applyUpdate(buildTableRestoreRequest("arn:bucket", pc))
if state.metadataLocation != prior.metadataLocation {
t.Fatalf("after rollback location = %q, want %q", state.metadataLocation, prior.metadataLocation)
}
if state.metadataVersion != prior.metadataVersion {
t.Fatalf("after rollback version = %d, want %d", state.metadataVersion, prior.metadataVersion)
}
if !bytes.Equal(state.metadata.FullMetadata, prior.metadata.FullMetadata) {
t.Fatalf("after rollback FullMetadata = %q, want %q", state.metadata.FullMetadata, prior.metadata.FullMetadata)
}
if state.metadata.Iceberg.TableUUID != prior.metadata.Iceberg.TableUUID {
t.Fatalf("after rollback TableUUID = %q, want %q", state.metadata.Iceberg.TableUUID, prior.metadata.Iceberg.TableUUID)
}
}
// TestCleanupSafeMetadataKeepsFailedRollback proves a flip failure at index 2
// where table 0's rollback also fails leaves table 0's new metadata in place
// (its pointer still references it), while the rolled-back table 1 and the
// never-flipped tables 2 and 3 are safe to delete.
func TestCleanupSafeMetadataKeepsFailedRollback(t *testing.T) {
prepared := make([]preparedTableCommit, 4)
rolledBack := []bool{false, true} // tables 0 and 1 were flipped; 0's rollback failed
safe := cleanupSafeMetadata(prepared, 2, rolledBack)
want := []bool{false, true, true, true}
for i := range want {
if safe[i] != want[i] {
t.Fatalf("safe[%d] = %v, want %v (full %v)", i, safe[i], want[i], safe)
}
}
}
// TestCleanupSafeMetadataFirstFlipFails covers a flip failure at the first table:
// nothing was flipped, so every written file is safe to delete.
func TestCleanupSafeMetadataFirstFlipFails(t *testing.T) {
prepared := make([]preparedTableCommit, 3)
safe := cleanupSafeMetadata(prepared, 0, nil)
for i, ok := range safe {
if !ok {
t.Fatalf("safe[%d] = false, want true (full %v)", i, safe)
}
}
}
// TestCleanupSafeMetadataAllRolledBack covers the common case where every prior
// flip rolls back cleanly, so all metadata files are safe to delete.
func TestCleanupSafeMetadataAllRolledBack(t *testing.T) {
prepared := make([]preparedTableCommit, 3)
safe := cleanupSafeMetadata(prepared, 2, []bool{true, true})
for i, ok := range safe {
if !ok {
t.Fatalf("safe[%d] = false, want true (full %v)", i, safe)
}
}
}
// tableEntry models the persisted table fields and the partial-update rules
// handleUpdateTable applies, so rollback can be exercised without a live filer.
type tableEntry struct {
metadataLocation string
metadataVersion int
metadata *s3tables.TableMetadata
}
func (e tableEntry) clone() *tableEntry {
return &tableEntry{
metadataLocation: e.metadataLocation,
metadataVersion: e.metadataVersion,
metadata: cloneTableMetadata(e.metadata),
}
}
func (e *tableEntry) applyUpdate(req *s3tables.UpdateTableRequest) {
if req.Metadata != nil {
if e.metadata == nil {
e.metadata = &s3tables.TableMetadata{}
}
if req.Metadata.Iceberg != nil {
if e.metadata.Iceberg == nil {
e.metadata.Iceberg = &s3tables.IcebergMetadata{}
}
if req.Metadata.Iceberg.TableUUID != "" {
e.metadata.Iceberg.TableUUID = req.Metadata.Iceberg.TableUUID
}
}
if len(req.Metadata.FullMetadata) > 0 {
e.metadata.FullMetadata = req.Metadata.FullMetadata
}
}
if req.MetadataLocation != "" {
e.metadataLocation = req.MetadataLocation
}
if req.MetadataVersion > 0 {
e.metadataVersion = req.MetadataVersion
}
}

View File

@@ -0,0 +1,87 @@
package iceberg
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gorilla/mux"
)
func TestApplyNamespacePropertyUpdates(t *testing.T) {
current := map[string]string{"foo": "bar", "prop": "yes"}
props, summary := applyNamespacePropertyUpdates(current, []string{"abc"}, map[string]string{"prop": "no"})
// Removal of a missing key reports missing, not removed.
if !reflect.DeepEqual(summary.Removed, []string{}) {
t.Errorf("Removed = %v, want []", summary.Removed)
}
if !reflect.DeepEqual(summary.Updated, []string{"prop"}) {
t.Errorf("Updated = %v, want [prop]", summary.Updated)
}
if !reflect.DeepEqual(summary.Missing, []string{"abc"}) {
t.Errorf("Missing = %v, want [abc]", summary.Missing)
}
if props["prop"] != "no" || props["foo"] != "bar" {
t.Errorf("merged properties = %v, want foo=bar prop=no", props)
}
// Input map must not be mutated.
if current["prop"] != "yes" {
t.Errorf("source map mutated: prop = %q, want yes", current["prop"])
}
}
func TestApplyNamespacePropertyUpdatesRemoveExisting(t *testing.T) {
props, summary := applyNamespacePropertyUpdates(
map[string]string{"a": "1", "b": "2"}, []string{"a"}, nil)
if !reflect.DeepEqual(summary.Removed, []string{"a"}) {
t.Errorf("Removed = %v, want [a]", summary.Removed)
}
if _, ok := props["a"]; ok {
t.Errorf("key a should have been removed: %v", props)
}
if len(summary.Missing) != 0 || len(summary.Updated) != 0 {
t.Errorf("unexpected summary: %+v", summary)
}
}
func TestApplyNamespacePropertyUpdatesDuplicateRemovals(t *testing.T) {
// A repeated removal key must not be reported as both removed and missing.
_, summary := applyNamespacePropertyUpdates(
map[string]string{"a": "1"}, []string{"a", "a"}, nil)
if !reflect.DeepEqual(summary.Removed, []string{"a"}) {
t.Errorf("Removed = %v, want [a]", summary.Removed)
}
if len(summary.Missing) != 0 {
t.Errorf("Missing = %v, want []", summary.Missing)
}
}
// A key in both removals and updates is rejected before any backend call, so
// the conflict path is reachable without a filer.
func TestHandleUpdateNamespacePropertiesConflict(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/namespaces/ns/properties",
strings.NewReader(`{"removals":["x"],"updates":{"x":"1"}}`))
req = mux.SetURLVars(req, map[string]string{"namespace": "ns"})
rec := httptest.NewRecorder()
(&Server{}).handleUpdateNamespaceProperties(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want 422", rec.Code)
}
var resp ErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Error.Type != "UnprocessableEntityException" {
t.Fatalf("error type = %q, want UnprocessableEntityException", resp.Error.Type)
}
}

View File

@@ -0,0 +1,115 @@
package iceberg
import (
"encoding/json"
"errors"
"testing"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/apache/iceberg-go/view"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestIsViewNotFound(t *testing.T) {
cases := []struct {
err error
want bool
}{
{nil, false},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchView, Message: "view x not found"}, true},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchNamespace, Message: "namespace x not found"}, true},
{errors.New("view x not found"), true},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeViewAlreadyExists, Message: "already exists"}, false},
{errors.New("connection refused"), false},
}
for _, c := range cases {
if got := isViewNotFound(c.err); got != c.want {
t.Errorf("isViewNotFound(%v) = %v, want %v", c.err, got, c.want)
}
}
}
func TestIsViewAlreadyExists(t *testing.T) {
cases := []struct {
err error
want bool
}{
{nil, false},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeViewAlreadyExists, Message: "already exists"}, true},
{errors.New("view already exists"), true},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchView, Message: "not found"}, false},
{errors.New("connection refused"), false},
}
for _, c := range cases {
if got := isViewAlreadyExists(c.err); got != c.want {
t.Errorf("isViewAlreadyExists(%v) = %v, want %v", c.err, got, c.want)
}
}
}
func TestViewPropertiesCopiesAndIsNonNil(t *testing.T) {
if got := viewProperties(nil); got == nil {
t.Fatal("viewProperties(nil) = nil, want non-nil")
}
src := iceberg.Properties{"k": "v"}
out := viewProperties(src)
out["k2"] = "v2"
if _, ok := src["k2"]; ok {
t.Fatal("viewProperties did not copy: mutation leaked into source")
}
if out["k"] != "v" {
t.Fatalf("viewProperties dropped key: got %q", out["k"])
}
}
// TestViewResponseRoundTrip verifies a built view metadata marshals into the
// ViewResponse wire shape and parses back via the iceberg-go view parser.
func TestViewResponseRoundTrip(t *testing.T) {
schema := iceberg.NewSchemaWithIdentifiers(0, nil,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int32, Required: true},
)
ver, err := view.NewVersionFromSQL(1, schema.ID, "SELECT 1", table.Identifier{"ns"})
if err != nil {
t.Fatal(err)
}
md, err := view.NewMetadata(ver, schema, "s3://bucket/ns/v", iceberg.Properties{})
if err != nil {
t.Fatal(err)
}
resp := ViewResponse{
MetadataLocation: "s3://bucket/ns/v/metadata/v1.metadata.json",
Metadata: md,
Config: iceberg.Properties{},
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
if _, ok := raw["metadata-location"]; !ok {
t.Fatal("response missing metadata-location")
}
if _, ok := raw["metadata"]; !ok {
t.Fatal("response missing metadata")
}
var back ViewResponse
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("unmarshal back: %v", err)
}
if back.Metadata == nil {
t.Fatal("metadata nil after round-trip")
}
if back.Metadata.CurrentVersion().VersionID != 1 {
t.Fatalf("current version = %d, want 1", back.Metadata.CurrentVersion().VersionID)
}
if back.MetadataLocation != resp.MetadataLocation {
t.Fatalf("metadata-location = %q, want %q", back.MetadataLocation, resp.MetadataLocation)
}
}

View File

@@ -0,0 +1,71 @@
package iceberg
import (
"encoding/json"
"net/http/httptest"
"net/url"
"testing"
)
// The admin console tells users that every table bucket is its own Iceberg
// catalog, so the /v1/config handshake must turn whichever spelling of that
// bucket the client sends into overrides.prefix. PyIceberg only sends the
// warehouse on this one call, so a dropped value leaves it talking to the
// default bucket for the rest of the session.
func TestHandleConfigWarehouseSpellings(t *testing.T) {
tests := []struct {
name string
warehouse string
wantPrefix string
wantWarehouse string
}{
{
name: "s3 location",
warehouse: "s3://seaweed-iceberg/",
wantPrefix: "seaweed-iceberg",
wantWarehouse: "s3://seaweed-iceberg",
},
{
name: "bare bucket name",
warehouse: "seaweed-iceberg",
wantPrefix: "seaweed-iceberg",
wantWarehouse: "s3://seaweed-iceberg",
},
{
name: "table bucket ARN",
warehouse: "arn:aws:s3tables:us-east-1:admin:bucket/seaweed-iceberg",
wantPrefix: "seaweed-iceberg",
wantWarehouse: "s3://seaweed-iceberg",
},
{
name: "no warehouse",
warehouse: "",
},
{
name: "unusable value",
warehouse: "file:///tmp/wh",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
target := "/v1/config"
if tc.warehouse != "" {
target += "?" + url.Values{"warehouse": {tc.warehouse}}.Encode()
}
r := httptest.NewRequest("GET", target, nil)
rec := httptest.NewRecorder()
(&Server{}).handleConfig(rec, r)
var config CatalogConfig
if err := json.Unmarshal(rec.Body.Bytes(), &config); err != nil {
t.Fatalf("decode config: %v", err)
}
if got := config.Overrides["prefix"]; got != tc.wantPrefix {
t.Errorf("overrides.prefix = %q, want %q", got, tc.wantPrefix)
}
if got := config.Defaults["warehouse"]; got != tc.wantWarehouse {
t.Errorf("defaults.warehouse = %q, want %q", got, tc.wantWarehouse)
}
})
}
}

View File

@@ -0,0 +1,618 @@
package iceberg
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/linkedin/goavro/v2"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
)
// Some engines (ClickHouse's experimental Iceberg writes among them) commit
// snapshots whose manifest avro files omit the spec's field-id annotations and
// reference files by bucket-relative path. Both make the snapshot unreadable
// for other engines even though the data itself is fine. Since the catalog is
// the commit chokepoint, add-snapshot updates are repaired here: the manifest
// list and manifests are transcoded with the annotations added and the paths
// absolutized, and the snapshot is pointed at the repaired copies.
// avroFieldIDs describes the spec-mandated ids for one avro field and the
// fields nested beneath it (through unions, arrays, and records).
type avroFieldIDs struct {
id int
elementID int
children map[string]avroFieldIDs
}
// manifestListFieldIDs returns the fixed v2 manifest_file schema ids.
func manifestListFieldIDs() map[string]avroFieldIDs {
return map[string]avroFieldIDs{
"manifest_path": {id: 500},
"manifest_length": {id: 501},
"partition_spec_id": {id: 502},
"content": {id: 517},
"sequence_number": {id: 515},
"min_sequence_number": {id: 516},
"added_snapshot_id": {id: 503},
"added_files_count": {id: 504},
"existing_files_count": {id: 505},
"deleted_files_count": {id: 506},
"added_rows_count": {id: 512},
"existing_rows_count": {id: 513},
"deleted_rows_count": {id: 514},
"partitions": {id: 507, elementID: 508, children: map[string]avroFieldIDs{
"contains_null": {id: 509},
"contains_nan": {id: 518},
"lower_bound": {id: 510},
"upper_bound": {id: 511},
}},
"key_metadata": {id: 519},
"first_row_id": {id: 520},
}
}
// manifestEntryFieldIDs returns the fixed v2 manifest_entry schema ids. The
// partition struct is table-specific; its ids come from the partition-spec
// JSON embedded in the manifest's OCF metadata.
func manifestEntryFieldIDs(partitionIDs map[string]avroFieldIDs) map[string]avroFieldIDs {
return map[string]avroFieldIDs{
"status": {id: 0},
"snapshot_id": {id: 1},
"sequence_number": {id: 3},
"file_sequence_number": {id: 4},
"data_file": {id: 2, children: map[string]avroFieldIDs{
"content": {id: 134},
"file_path": {id: 100},
"file_format": {id: 101},
"partition": {id: 102, children: partitionIDs},
"record_count": {id: 103},
"file_size_in_bytes": {id: 104},
"column_sizes": {id: 108, children: kvFieldIDs(117, 118)},
"value_counts": {id: 109, children: kvFieldIDs(119, 120)},
"null_value_counts": {id: 110, children: kvFieldIDs(121, 122)},
"nan_value_counts": {id: 137, children: kvFieldIDs(138, 139)},
"lower_bounds": {id: 125, children: kvFieldIDs(126, 127)},
"upper_bounds": {id: 128, children: kvFieldIDs(129, 130)},
"key_metadata": {id: 131},
"split_offsets": {id: 132, elementID: 133},
"equality_ids": {id: 135, elementID: 136},
"sort_order_id": {id: 140},
"first_row_id": {id: 142},
"referenced_data_file": {id: 143},
"content_offset": {id: 144},
"content_size_in_bytes": {id: 145},
}},
}
}
func kvFieldIDs(keyID, valueID int) map[string]avroFieldIDs {
return map[string]avroFieldIDs{
"key": {id: keyID},
"value": {id: valueID},
}
}
// partitionFieldIDsFromSpec extracts name -> field-id from the partition-spec
// JSON carried in a manifest's OCF metadata.
func partitionFieldIDsFromSpec(specJSON []byte) (map[string]avroFieldIDs, error) {
if len(specJSON) == 0 {
return map[string]avroFieldIDs{}, nil
}
var fields []struct {
Name string `json:"name"`
FieldID int `json:"field-id"`
}
if err := json.Unmarshal(specJSON, &fields); err != nil {
return nil, fmt.Errorf("parse partition-spec metadata: %w", err)
}
ids := make(map[string]avroFieldIDs, len(fields))
for _, f := range fields {
ids[f.Name] = avroFieldIDs{id: f.FieldID}
}
return ids, nil
}
// annotateAvroRecordFields adds "field-id" to every field of a parsed avro
// record schema, recursing into nested types. Returns whether anything was
// added, and ok=false when a field has no known id (the schema is left for an
// unknown writer dialect rather than half-annotated).
func annotateAvroRecordFields(record map[string]any, ids map[string]avroFieldIDs) (changed, ok bool) {
fields, _ := record["fields"].([]any)
if len(fields) == 0 {
return false, true
}
for _, f := range fields {
field, isMap := f.(map[string]any)
if !isMap {
return changed, false
}
name, _ := field["name"].(string)
spec, known := ids[name]
if !known {
return changed, false
}
if _, has := field["field-id"]; !has {
field["field-id"] = spec.id
changed = true
}
typeChanged, typeOK := annotateAvroType(field["type"], spec)
changed = changed || typeChanged
if !typeOK {
return changed, false
}
}
return changed, true
}
// annotateAvroType annotates the schema node of a single field: unions are
// followed through their non-null branches, arrays get "element-id", and
// nested records recurse with the field's child ids.
func annotateAvroType(node any, spec avroFieldIDs) (changed, ok bool) {
switch t := node.(type) {
case []any: // union
for _, branch := range t {
branchChanged, branchOK := annotateAvroType(branch, spec)
changed = changed || branchChanged
if !branchOK {
return changed, false
}
}
return changed, true
case map[string]any:
switch t["type"] {
case "record":
childIDs := spec.children
if childIDs == nil {
childIDs = map[string]avroFieldIDs{}
}
return annotateAvroRecordFields(t, childIDs)
case "array":
if spec.elementID != 0 {
if _, has := t["element-id"]; !has {
t["element-id"] = spec.elementID
changed = true
}
}
itemChanged, itemOK := annotateAvroType(t["items"], spec)
return changed || itemChanged, itemOK
case "map":
// The spec wants key-id/value-id on genuine avro maps; this repair
// only knows the array-of-key_value encoding, so leave the file to
// its writer rather than half-annotating it.
return changed, false
default:
return false, true
}
default: // primitive name or named-type reference
return false, true
}
}
// transcodeManifestAvro decodes an avro OCF file, annotates its schema with
// the given ids, applies mutate to each datum and fixMeta to the OCF metadata,
// and re-encodes. When nothing changes, the original bytes are returned
// unchanged.
func transcodeManifestAvro(raw []byte, ids map[string]avroFieldIDs, mutate func(map[string]any) bool, fixMeta func(map[string][]byte) bool) ([]byte, bool, error) {
reader, err := goavro.NewOCFReader(bytes.NewReader(raw))
if err != nil {
return nil, false, fmt.Errorf("open avro: %w", err)
}
meta := reader.MetaData()
outMeta := make(map[string][]byte, len(meta))
for k, v := range meta {
if k == "avro.schema" || k == "avro.codec" {
continue
}
outMeta[k] = v
}
metaChanged := false
if fixMeta != nil {
metaChanged = fixMeta(outMeta)
}
var schema map[string]any
if err := json.Unmarshal(meta["avro.schema"], &schema); err != nil {
return nil, false, fmt.Errorf("parse avro schema: %w", err)
}
schemaChanged, ok := annotateAvroRecordFields(schema, ids)
if !ok {
return nil, false, fmt.Errorf("avro schema has fields outside the Iceberg manifest spec")
}
datumChanged := false
var datums []any
for reader.Scan() {
datum, err := reader.Read()
if err != nil {
return nil, false, fmt.Errorf("read avro datum: %w", err)
}
if record, isMap := datum.(map[string]any); isMap && mutate != nil {
if mutate(record) {
datumChanged = true
}
}
datums = append(datums, datum)
}
if err := reader.Err(); err != nil {
return nil, false, fmt.Errorf("scan avro: %w", err)
}
if !schemaChanged && !datumChanged && !metaChanged {
return raw, false, nil
}
annotatedSchema, err := json.Marshal(schema)
if err != nil {
return nil, false, err
}
var buf bytes.Buffer
writer, err := goavro.NewOCFWriter(goavro.OCFConfig{
W: &buf,
Schema: string(annotatedSchema),
MetaData: outMeta,
})
if err != nil {
return nil, false, fmt.Errorf("create avro writer: %w", err)
}
if err := writer.Append(datums); err != nil {
return nil, false, fmt.Errorf("write avro datums: %w", err)
}
return buf.Bytes(), true, nil
}
// manifestStore abstracts reading and writing files in a table's metadata
// directory so the repair logic is testable without a filer.
type manifestStore interface {
loadFile(ctx context.Context, location string) ([]byte, error)
saveFile(ctx context.Context, location string, data []byte) error
}
// absolutizeLocation resolves a bucket-relative path against the bucket of the
// table location. Locations that already carry a scheme pass through.
func absolutizeLocation(location, tableLocation string) string {
if location == "" || strings.Contains(location, "://") {
return location
}
bucket, _, err := parseS3Location(tableLocation)
if err != nil {
return location
}
return "s3://" + bucket + "/" + strings.TrimPrefix(location, "/")
}
// metadataFileName returns the file name when location is a direct child of
// the table's metadata directory, which is the only place repair will read
// from or write to.
func metadataFileName(tableLocation, location string) (string, bool) {
prefix := strings.TrimSuffix(tableLocation, "/") + "/metadata/"
name := strings.TrimPrefix(location, prefix)
if name == location || name == "" || strings.Contains(name, "/") {
return "", false
}
return name, true
}
const repairedManifestPrefix = "repaired-"
// maxRepairableManifestSize bounds how much manifest data the repair path
// buffers in memory; anything larger fails the repair and the commit
// proceeds with the writer's original files.
const maxRepairableManifestSize = 64 << 20
// hasFieldIDAnnotations is the cheap compliance probe: spec-compliant writers
// annotate every manifest avro schema with field-id, and a writer that
// annotates the manifest list annotates its manifests too.
func hasFieldIDAnnotations(raw []byte) bool {
reader, err := goavro.NewOCFReader(bytes.NewReader(raw))
if err != nil {
return false
}
return bytes.Contains(reader.MetaData()["avro.schema"], []byte(`"field-id"`))
}
// repairManifestList loads a snapshot's manifest list and, when it needs
// repair, rewrites it (and the manifests it references) into spec-compliant
// copies next to the originals. Returns the manifest list location the
// snapshot should reference.
func repairManifestList(ctx context.Context, store manifestStore, tableLocation, listLocation string) (string, bool, error) {
absListLocation := absolutizeLocation(listLocation, tableLocation)
listName, inMetadataDir := metadataFileName(tableLocation, absListLocation)
if !inMetadataDir {
return listLocation, false, nil
}
listBytes, err := store.loadFile(ctx, absListLocation)
if err != nil {
return listLocation, false, fmt.Errorf("load manifest list: %w", err)
}
if absListLocation == listLocation && hasFieldIDAnnotations(listBytes) {
return listLocation, false, nil
}
metadataDir := strings.TrimSuffix(tableLocation, "/") + "/metadata/"
var manifestErr error
repairedList, listChanged, err := transcodeManifestAvro(listBytes, manifestListFieldIDs(), func(record map[string]any) bool {
manifestPath, _ := record["manifest_path"].(string)
if manifestPath == "" || manifestErr != nil {
return false
}
absManifest := absolutizeLocation(manifestPath, tableLocation)
recordChanged := absManifest != manifestPath
if recordChanged {
record["manifest_path"] = absManifest
}
manifestName, ok := metadataFileName(tableLocation, absManifest)
if !ok {
return recordChanged
}
manifestBytes, err := store.loadFile(ctx, absManifest)
if err != nil {
manifestErr = fmt.Errorf("load manifest %s: %w", absManifest, err)
return recordChanged
}
repaired, changed, err := repairManifest(manifestBytes, tableLocation, manifestContentValue(record))
if err != nil {
manifestErr = fmt.Errorf("repair manifest %s: %w", absManifest, err)
return recordChanged
}
if !changed {
return recordChanged
}
repairedLocation := metadataDir + repairedManifestPrefix + manifestName
if err := store.saveFile(ctx, repairedLocation, repaired); err != nil {
manifestErr = fmt.Errorf("save repaired manifest %s: %w", repairedLocation, err)
return recordChanged
}
record["manifest_path"] = repairedLocation
record["manifest_length"] = int64(len(repaired))
return true
}, nil)
if err != nil {
return listLocation, false, err
}
if manifestErr != nil {
return listLocation, false, manifestErr
}
if !listChanged && absListLocation == listLocation {
return listLocation, false, nil
}
repairedListLocation := metadataDir + repairedManifestPrefix + listName
if listChanged {
if err := store.saveFile(ctx, repairedListLocation, repairedList); err != nil {
return listLocation, false, fmt.Errorf("save repaired manifest list: %w", err)
}
return repairedListLocation, true, nil
}
// Only the pointer was relative; the file itself is fine.
return absListLocation, true, nil
}
// manifestContentValue reads the manifest-list entry's content field
// (0 = data, 1 = deletes); v1 lists have no such field and default to data.
// Union-typed values arrive from goavro as a single-branch map.
func manifestContentValue(record map[string]any) int {
value := record["content"]
if union, isUnion := value.(map[string]any); isUnion {
for _, branch := range union {
value = branch
break
}
}
switch v := value.(type) {
case int32:
return int(v)
case int64:
return int(v)
case int:
return v
}
return 0
}
// fixManifestOCFMetadata fixes OCF metadata keys the spec requires of
// manifests but lax writers omit or contradict: "content" must agree with the
// manifest-list entry, which is what readers trust for scan planning, and
// "partition-spec-id" (ClickHouse writes the underscore variant).
func fixManifestOCFMetadata(listContent int) func(map[string][]byte) bool {
return func(meta map[string][]byte) bool {
changed := false
content := "data"
if listContent == 1 {
content = "deletes"
}
if string(meta["content"]) != content {
meta["content"] = []byte(content)
changed = true
}
if _, ok := meta["partition-spec-id"]; !ok {
if v, ok := meta["partition_spec_id"]; ok {
meta["partition-spec-id"] = v
changed = true
}
}
return changed
}
}
// repairManifest annotates a single manifest's schema and absolutizes the
// data file paths inside it. listContent is the content declared by the
// manifest-list entry referencing this manifest.
func repairManifest(raw []byte, tableLocation string, listContent int) ([]byte, bool, error) {
reader, err := goavro.NewOCFReader(bytes.NewReader(raw))
if err != nil {
return nil, false, fmt.Errorf("open manifest avro: %w", err)
}
partitionIDs, err := partitionFieldIDsFromSpec(reader.MetaData()["partition-spec"])
if err != nil {
return nil, false, err
}
return transcodeManifestAvro(raw, manifestEntryFieldIDs(partitionIDs), func(record map[string]any) bool {
dataFile, _ := record["data_file"].(map[string]any)
if dataFile == nil {
return false
}
filePath, _ := dataFile["file_path"].(string)
absPath := absolutizeLocation(filePath, tableLocation)
if absPath == filePath {
return false
}
dataFile["file_path"] = absPath
return true
}, fixManifestOCFMetadata(listContent))
}
// repairAddSnapshotUpdates rewrites the manifest-list references of
// add-snapshot updates whose manifests need repair. Repair is best effort: on
// any error the original update is kept and the commit proceeds as it would
// have without repair.
func repairAddSnapshotUpdates(ctx context.Context, store manifestStore, tableLocation string, rawUpdates []json.RawMessage) ([]json.RawMessage, bool) {
changedAny := false
out := make([]json.RawMessage, len(rawUpdates))
copy(out, rawUpdates)
for i, rawUpdate := range rawUpdates {
var probe struct {
Action string `json:"action"`
}
if err := json.Unmarshal(rawUpdate, &probe); err != nil || probe.Action != "add-snapshot" {
continue
}
var update map[string]json.RawMessage
if err := json.Unmarshal(rawUpdate, &update); err != nil {
continue
}
var snapshot map[string]json.RawMessage
if err := json.Unmarshal(update["snapshot"], &snapshot); err != nil {
continue
}
var listLocation string
if err := json.Unmarshal(snapshot["manifest-list"], &listLocation); err != nil || listLocation == "" {
continue
}
repairedLocation, changed, err := repairManifestList(ctx, store, tableLocation, listLocation)
if err != nil {
glog.Warningf("Iceberg: manifest repair for %s skipped: %v", listLocation, err)
continue
}
if !changed {
continue
}
locationJSON, err := json.Marshal(repairedLocation)
if err != nil {
continue
}
snapshot["manifest-list"] = locationJSON
snapshotJSON, err := json.Marshal(snapshot)
if err != nil {
continue
}
update["snapshot"] = snapshotJSON
updateJSON, err := json.Marshal(update)
if err != nil {
continue
}
out[i] = updateJSON
changedAny = true
glog.V(1).Infof("Iceberg: repaired manifest list %s -> %s", listLocation, repairedLocation)
}
return out, changedAny
}
// serverManifestStore reads and writes table metadata files through the filer.
type serverManifestStore struct {
server *Server
tableLocation string
}
func (st *serverManifestStore) split(location string) (bucket, tablePath, fileName string, err error) {
fileName, ok := metadataFileName(st.tableLocation, location)
if !ok {
return "", "", "", fmt.Errorf("%s is outside the table metadata directory", location)
}
bucket, tablePath, err = parseS3Location(st.tableLocation)
return bucket, tablePath, fileName, err
}
func (st *serverManifestStore) loadFile(ctx context.Context, location string) ([]byte, error) {
bucket, tablePath, fileName, err := st.split(location)
if err != nil {
return nil, err
}
return st.server.loadTableMetadataBlob(ctx, bucket, tablePath, fileName)
}
func (st *serverManifestStore) saveFile(ctx context.Context, location string, data []byte) error {
bucket, tablePath, fileName, err := st.split(location)
if err != nil {
return err
}
return st.server.saveMetadataBlob(ctx, bucket, tablePath, fileName, data, "application/avro", false)
}
// repairAddSnapshotManifests is the server entry point used by the commit
// handlers.
func (s *Server) repairAddSnapshotManifests(ctx context.Context, tableLocation string, rawUpdates []json.RawMessage) ([]json.RawMessage, bool) {
store := &serverManifestStore{server: s, tableLocation: tableLocation}
return repairAddSnapshotUpdates(ctx, store, tableLocation, rawUpdates)
}
// lookupFileIDAdapter lets filer.StreamContent resolve volume locations
// through the iceberg server's filer client.
type lookupFileIDAdapter struct {
fn wdclient.LookupFileIdFunctionType
}
func (a *lookupFileIDAdapter) GetLookupFileIdFunction() wdclient.LookupFileIdFunctionType {
return a.fn
}
// loadTableMetadataBlob reads a file from the table's metadata directory,
// following chunks for files that were written through the S3 gateway rather
// than inline by the catalog itself.
func (s *Server) loadTableMetadataBlob(ctx context.Context, bucketName, tablePath, fileName string) ([]byte, error) {
var entry *filer_pb.Entry
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: metadataDirPath(bucketName, tablePath),
Name: fileName,
})
if err != nil {
return err
}
entry = resp.Entry
return nil
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, fmt.Errorf("no entry for %s", fileName)
}
if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 {
return entry.Content, nil
}
if size := filer.FileSize(entry); size > maxRepairableManifestSize {
return nil, fmt.Errorf("%s is %d bytes, larger than the %d byte repair limit", fileName, size, maxRepairableManifestSize)
}
fullClient, ok := s.filerClient.(filer_pb.FilerClient)
if !ok {
return nil, fmt.Errorf("filer client cannot resolve chunk locations")
}
var buf bytes.Buffer
lookup := &lookupFileIDAdapter{fn: filer.LookupFn(fullClient)}
if err := filer.StreamContent(lookup, &buf, entry.GetChunks(), 0, int64(filer.FileSize(entry))); err != nil {
return nil, fmt.Errorf("read %s: %w", fileName, err)
}
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,281 @@
package iceberg
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
icebergmanifest "github.com/apache/iceberg-go"
"github.com/linkedin/goavro/v2"
)
// The testdata fixtures were written by ClickHouse 25.8's experimental
// Iceberg insert against a SeaweedFS table bucket. clickhouse_rel holds the
// default output (bucket-relative paths, no field-id annotations);
// clickhouse_abs was written with write_full_path_in_iceberg_metadata=1
// (absolute paths, still no field-ids).
type fakeManifestStore struct {
files map[string][]byte
saved map[string][]byte
}
func newFakeManifestStore() *fakeManifestStore {
return &fakeManifestStore{files: map[string][]byte{}, saved: map[string][]byte{}}
}
func (f *fakeManifestStore) loadFile(_ context.Context, location string) ([]byte, error) {
if data, ok := f.files[location]; ok {
return data, nil
}
return nil, fmt.Errorf("no file at %s", location)
}
func (f *fakeManifestStore) saveFile(_ context.Context, location string, data []byte) error {
f.files[location] = data
f.saved[location] = data
return nil
}
func loadFixture(t *testing.T, parts ...string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join(append([]string{"testdata"}, parts...)...))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
return data
}
const (
relTableLocation = "s3://iceberg-tables/wprobe/target_rel"
relListName = "snap-449670589-2-48b874af-90f6-4e5e-8002-df91e59c8a0c.avro"
relManifestName = "690958a2-9936-4a04-8117-d8104c977195.avro"
absTableLocation = "s3://iceberg-tables/wprobe/target"
absListName = "snap-331611856-2-51798e6c-427b-4fc2-9149-99e75d96e045.avro"
absManifestName = "b317f0e5-10f3-41d6-86bc-8b42f01f3e86.avro"
)
// verifyRepairedManifests checks the repaired files with iceberg-go's strict
// typed readers: the manifest list parses, references the repaired manifest
// with the right length, and the manifest's data file path is absolute.
func verifyRepairedManifests(t *testing.T, store *fakeManifestStore, listLocation, tableLocation string) {
t.Helper()
listBytes, ok := store.files[listLocation]
if !ok {
t.Fatalf("repaired manifest list %s was not saved", listLocation)
}
if !bytes.Contains(listBytes, []byte(`"field-id"`)) {
t.Fatalf("repaired manifest list schema has no field-id annotations")
}
manifests, err := icebergmanifest.ReadManifestList(bytes.NewReader(listBytes))
if err != nil {
t.Fatalf("iceberg-go cannot read repaired manifest list: %v", err)
}
if len(manifests) != 1 {
t.Fatalf("manifest list has %d entries, want 1", len(manifests))
}
manifestPath := manifests[0].FilePath()
if !strings.HasPrefix(manifestPath, tableLocation+"/metadata/") {
t.Fatalf("manifest path %s is not absolute under the table location", manifestPath)
}
manifestBytes, ok := store.files[manifestPath]
if !ok {
t.Fatalf("manifest %s not present in store", manifestPath)
}
if manifests[0].Length() != int64(len(manifestBytes)) {
t.Fatalf("manifest_length %d != stored size %d", manifests[0].Length(), len(manifestBytes))
}
if !bytes.Contains(manifestBytes, []byte(`"field-id"`)) {
t.Fatalf("repaired manifest schema has no field-id annotations")
}
entries, err := icebergmanifest.ReadManifest(manifests[0], bytes.NewReader(manifestBytes), false)
if err != nil {
t.Fatalf("iceberg-go cannot read repaired manifest: %v", err)
}
if len(entries) != 1 {
t.Fatalf("manifest has %d entries, want 1", len(entries))
}
dataFile := entries[0].DataFile()
if !strings.HasPrefix(dataFile.FilePath(), "s3://iceberg-tables/") {
t.Fatalf("data file path %s is not absolute", dataFile.FilePath())
}
if dataFile.Count() != 2 {
t.Fatalf("record count %d, want 2", dataFile.Count())
}
if len(dataFile.LowerBoundValues()) == 0 {
t.Fatalf("lower bounds were lost in repair")
}
}
func addSnapshotUpdateJSON(t *testing.T, manifestList string) []json.RawMessage {
t.Helper()
update := fmt.Sprintf(`{"action":"add-snapshot","snapshot":{"snapshot-id":449670589,"sequence-number":1,"timestamp-ms":1754625869000,"manifest-list":%q,"summary":{"operation":"append"},"schema-id":0}}`, manifestList)
return []json.RawMessage{json.RawMessage(update)}
}
func TestRepairClickHouseRelativeManifests(t *testing.T) {
store := newFakeManifestStore()
store.files[relTableLocation+"/metadata/"+relListName] = loadFixture(t, "clickhouse_rel", relListName)
store.files[relTableLocation+"/metadata/"+relManifestName] = loadFixture(t, "clickhouse_rel", relManifestName)
rawUpdates := addSnapshotUpdateJSON(t, "wprobe/target_rel/metadata/"+relListName)
repaired, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
if !changed {
t.Fatal("expected repair to change the update")
}
var update struct {
Snapshot struct {
ManifestList string `json:"manifest-list"`
SnapshotID int64 `json:"snapshot-id"`
Summary struct {
Operation string `json:"operation"`
} `json:"summary"`
} `json:"snapshot"`
}
if err := json.Unmarshal(repaired[0], &update); err != nil {
t.Fatalf("unmarshal repaired update: %v", err)
}
wantList := relTableLocation + "/metadata/" + repairedManifestPrefix + relListName
if update.Snapshot.ManifestList != wantList {
t.Fatalf("manifest-list = %s, want %s", update.Snapshot.ManifestList, wantList)
}
// The rest of the snapshot survives the rewrite.
if update.Snapshot.SnapshotID != 449670589 || update.Snapshot.Summary.Operation != "append" {
t.Fatalf("snapshot fields were disturbed: %s", repaired[0])
}
verifyRepairedManifests(t, store, wantList, relTableLocation)
}
func TestRepairClickHouseAbsoluteManifests(t *testing.T) {
store := newFakeManifestStore()
listLocation := absTableLocation + "/metadata/" + absListName
store.files[listLocation] = loadFixture(t, "clickhouse_abs", absListName)
store.files[absTableLocation+"/metadata/"+absManifestName] = loadFixture(t, "clickhouse_abs", absManifestName)
repaired, changed, err := repairManifestList(context.Background(), store, absTableLocation, listLocation)
if err != nil {
t.Fatalf("repairManifestList: %v", err)
}
if !changed {
t.Fatal("expected repair for missing field-ids")
}
verifyRepairedManifests(t, store, repaired, absTableLocation)
}
func TestRepairedManifestsAreLeftAlone(t *testing.T) {
store := newFakeManifestStore()
listLocation := absTableLocation + "/metadata/" + absListName
store.files[listLocation] = loadFixture(t, "clickhouse_abs", absListName)
store.files[absTableLocation+"/metadata/"+absManifestName] = loadFixture(t, "clickhouse_abs", absManifestName)
firstPass, changed, err := repairManifestList(context.Background(), store, absTableLocation, listLocation)
if err != nil || !changed {
t.Fatalf("first pass: changed=%v err=%v", changed, err)
}
store.saved = map[string][]byte{}
secondPass, changed, err := repairManifestList(context.Background(), store, absTableLocation, firstPass)
if err != nil {
t.Fatalf("second pass: %v", err)
}
if changed || secondPass != firstPass {
t.Fatalf("second pass rewrote an already-repaired list: changed=%v location=%s", changed, secondPass)
}
if len(store.saved) != 0 {
t.Fatalf("second pass saved files: %v", store.saved)
}
}
func TestRepairKeepsDeleteManifestContent(t *testing.T) {
// The fixture lacks the OCF "content" key; the repaired copy must take the
// content declared by the manifest-list entry, not default to data.
raw := loadFixture(t, "clickhouse_rel", relManifestName)
repaired, changed, err := repairManifest(raw, relTableLocation, 1)
if err != nil || !changed {
t.Fatalf("repairManifest: changed=%v err=%v", changed, err)
}
reader, err := goavro.NewOCFReader(bytes.NewReader(repaired))
if err != nil {
t.Fatalf("read repaired manifest: %v", err)
}
if got := string(reader.MetaData()["content"]); got != "deletes" {
t.Fatalf(`OCF content = %q, want "deletes"`, got)
}
asData, _, err := repairManifest(raw, relTableLocation, 0)
if err != nil {
t.Fatalf("repairManifest: %v", err)
}
reader, err = goavro.NewOCFReader(bytes.NewReader(asData))
if err != nil {
t.Fatalf("read repaired manifest: %v", err)
}
if got := string(reader.MetaData()["content"]); got != "data" {
t.Fatalf(`OCF content = %q, want "data"`, got)
}
// An existing content value that contradicts the manifest-list entry is
// corrected, not preserved.
relabeled, changed, err := repairManifest(asData, relTableLocation, 1)
if err != nil || !changed {
t.Fatalf("repairManifest on mismatched content: changed=%v err=%v", changed, err)
}
reader, err = goavro.NewOCFReader(bytes.NewReader(relabeled))
if err != nil {
t.Fatalf("read relabeled manifest: %v", err)
}
if got := string(reader.MetaData()["content"]); got != "deletes" {
t.Fatalf(`OCF content after relabel = %q, want "deletes"`, got)
}
}
func TestManifestContentValue(t *testing.T) {
cases := []struct {
record map[string]any
want int
}{
{map[string]any{"content": int32(1)}, 1},
{map[string]any{"content": int64(1)}, 1},
{map[string]any{"content": map[string]any{"int": int32(1)}}, 1},
{map[string]any{"content": nil}, 0},
{map[string]any{}, 0},
}
for _, c := range cases {
if got := manifestContentValue(c.record); got != c.want {
t.Errorf("manifestContentValue(%v) = %d, want %d", c.record, got, c.want)
}
}
}
func TestRepairSkipsLocationsOutsideMetadataDir(t *testing.T) {
store := newFakeManifestStore()
rawUpdates := addSnapshotUpdateJSON(t, "s3://other-bucket/elsewhere/list.avro")
_, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
if changed {
t.Fatal("repair touched a manifest list outside the table metadata directory")
}
}
func TestRepairLeavesUnreadableListAlone(t *testing.T) {
store := newFakeManifestStore()
store.files[relTableLocation+"/metadata/junk.avro"] = []byte("not avro")
rawUpdates := addSnapshotUpdateJSON(t, relTableLocation+"/metadata/junk.avro")
repaired, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
if changed {
t.Fatal("repair claimed to change an unreadable manifest list")
}
if !bytes.Equal(repaired[0], rawUpdates[0]) {
t.Fatal("update was modified despite repair failure")
}
}

View File

@@ -0,0 +1,132 @@
package iceberg
import (
"bytes"
"encoding/json"
)
// specRequiredEmptyOrder is iterated in this fixed order so the byte-level
// append in ensureMetadataSpecCompliance produces deterministic output.
var specRequiredEmptyOrder = []string{
"current-snapshot-id",
"snapshots",
"snapshot-log",
"metadata-log",
"refs",
}
// specRequiredEmptyDefaults holds the sentinel values Iceberg requires when
// the corresponding state is empty.
var specRequiredEmptyDefaults = map[string]json.RawMessage{
"current-snapshot-id": json.RawMessage("-1"),
"snapshots": json.RawMessage("[]"),
"snapshot-log": json.RawMessage("[]"),
"metadata-log": json.RawMessage("[]"),
"refs": json.RawMessage("{}"),
}
// isJSONNull reports whether raw encodes the JSON null literal, ignoring
// insignificant whitespace allowed by RFC 8259.
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
// ensureMetadataSpecCompliance backfills Iceberg spec-required fields that
// iceberg-go v0.5.0 drops via `omitempty` on optional pointer/slice fields.
//
// For tables with no snapshots (e.g. freshly created tables), iceberg-go
// represents an empty state with nil/empty Go values, then strips those keys
// entirely from the JSON output. The Iceberg REST spec, however, requires
// these keys to be present even when empty — Java/Spark/Trino clients
// fail to parse responses missing them (most visibly with
// "Cannot parse missing long current-snapshot-id"). This helper rehydrates
// the missing keys without overwriting any present real values.
//
// Missing keys are spliced in at the byte level just before the closing
// brace so iceberg-go's struct-declared key order is preserved (a naive
// map remarshal would alphabetize every key in the document). The slower
// remarshal path is only used when an explicit JSON null needs replacing,
// which iceberg-go itself never emits.
//
// Returns the original bytes unchanged when parsing fails or no key needs
// to be added, to avoid corrupting an otherwise-valid payload.
func ensureMetadataSpecCompliance(raw []byte) []byte {
if len(raw) == 0 {
return raw
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return raw
}
// A top-level JSON null leaves obj == nil with no error; writes would
// panic on the slow path. It is not a valid TableMetadata document, so
// pass it through unchanged.
if obj == nil {
return raw
}
// A field is "missing" for spec purposes if it's absent OR encoded as
// JSON null. Some writers emit `"current-snapshot-id": null` for empty
// state instead of omitting; strict clients reject both the same way.
var toAppend []string
hasExplicitNull := false
for _, key := range specRequiredEmptyOrder {
v, present := obj[key]
switch {
case !present:
toAppend = append(toAppend, key)
case isJSONNull(v):
hasExplicitNull = true
}
}
if len(toAppend) == 0 && !hasExplicitNull {
return raw
}
if !hasExplicitNull {
return appendMissingObjectKeys(raw, toAppend, len(obj) > 0)
}
// Slow path: replace explicit nulls. Rare in practice, so the
// alphabetical key reordering inherent to map remarshal is acceptable.
for key, v := range obj {
if _, required := specRequiredEmptyDefaults[key]; required && isJSONNull(v) {
obj[key] = specRequiredEmptyDefaults[key]
}
}
for _, key := range toAppend {
obj[key] = specRequiredEmptyDefaults[key]
}
fixed, err := json.Marshal(obj)
if err != nil {
return raw
}
return fixed
}
// appendMissingObjectKeys splices the given keys into raw just before the
// closing brace of the top-level JSON object. raw is assumed to be a valid
// JSON object (the caller has already unmarshalled it). hasMembers tells us
// whether the object had any existing members, so we know whether to emit a
// leading comma for the first appended key.
func appendMissingObjectKeys(raw []byte, keys []string, hasMembers bool) []byte {
closeIdx := bytes.LastIndexByte(raw, '}')
if closeIdx < 0 {
return raw
}
var buf bytes.Buffer
buf.Grow(len(raw) + len(keys)*48)
buf.Write(raw[:closeIdx])
for _, key := range keys {
if hasMembers {
buf.WriteByte(',')
}
buf.WriteByte('"')
buf.WriteString(key)
buf.WriteString(`":`)
buf.Write(specRequiredEmptyDefaults[key])
hasMembers = true
}
buf.Write(raw[closeIdx:])
return buf.Bytes()
}

View File

@@ -0,0 +1,162 @@
package iceberg
import (
"encoding/json"
"testing"
)
func TestEnsureMetadataSpecCompliance_BackfillsMissingFields(t *testing.T) {
// Mirrors the real iceberg-go v0.5.0 output for a freshly created table:
// current-snapshot-id, snapshots, snapshot-log, metadata-log, refs all absent.
input := []byte(`{
"format-version": 2,
"table-uuid": "82e3eec4-3aee-414f-a444-94c03c641d20",
"location": "s3://s3table/default/t1",
"last-sequence-number": 0,
"last-updated-ms": 1779866785466,
"last-column-id": 2,
"current-schema-id": 0,
"default-spec-id": 0,
"last-partition-id": 999,
"default-sort-order-id": 0
}`)
out := ensureMetadataSpecCompliance(input)
var got map[string]json.RawMessage
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if v, ok := got["current-snapshot-id"]; !ok || string(v) != "-1" {
t.Errorf("current-snapshot-id missing or wrong: present=%v value=%s", ok, string(v))
}
for _, key := range []string{"snapshots", "snapshot-log", "metadata-log"} {
v, ok := got[key]
if !ok || string(v) != "[]" {
t.Errorf("%s missing or wrong: present=%v value=%s", key, ok, string(v))
}
}
if v, ok := got["refs"]; !ok || string(v) != "{}" {
t.Errorf("refs missing or wrong: present=%v value=%s", ok, string(v))
}
}
func TestEnsureMetadataSpecCompliance_PreservesExistingFields(t *testing.T) {
// Real snapshot id and refs must not be overwritten by sentinels.
input := []byte(`{
"format-version": 2,
"current-snapshot-id": 9876543210,
"snapshots": [{"snapshot-id": 9876543210}],
"snapshot-log": [{"snapshot-id": 9876543210, "timestamp-ms": 1}],
"metadata-log": [{"metadata-file": "v1.metadata.json", "timestamp-ms": 1}],
"refs": {"main": {"snapshot-id": 9876543210, "type": "branch"}}
}`)
out := ensureMetadataSpecCompliance(input)
var got map[string]json.RawMessage
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if string(got["current-snapshot-id"]) != "9876543210" {
t.Errorf("real snapshot id was overwritten: %s", string(got["current-snapshot-id"]))
}
if string(got["snapshots"]) == "[]" {
t.Errorf("non-empty snapshots was overwritten with empty array")
}
}
func TestEnsureMetadataSpecCompliance_ReplacesExplicitNullsWithSentinels(t *testing.T) {
// Some writers emit explicit JSON null for unset values instead of omitting
// the key. Strict Iceberg clients reject these the same way as missing keys.
input := []byte(`{
"format-version": 2,
"current-snapshot-id": null,
"snapshots": null,
"snapshot-log": null,
"metadata-log": null,
"refs": null
}`)
out := ensureMetadataSpecCompliance(input)
var got map[string]json.RawMessage
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if string(got["current-snapshot-id"]) != "-1" {
t.Errorf("current-snapshot-id should be replaced with -1, got %s", string(got["current-snapshot-id"]))
}
for _, key := range []string{"snapshots", "snapshot-log", "metadata-log"} {
if string(got[key]) != "[]" {
t.Errorf("%s should be replaced with [], got %s", key, string(got[key]))
}
}
if string(got["refs"]) != "{}" {
t.Errorf("refs should be replaced with {}, got %s", string(got["refs"]))
}
}
func TestEnsureMetadataSpecCompliance_InvalidJSONReturnedUnchanged(t *testing.T) {
input := []byte(`{not valid json`)
out := ensureMetadataSpecCompliance(input)
if string(out) != string(input) {
t.Errorf("invalid JSON should be returned unchanged; got %s", string(out))
}
}
func TestEnsureMetadataSpecCompliance_EmptyInputReturnedUnchanged(t *testing.T) {
if out := ensureMetadataSpecCompliance(nil); out != nil {
t.Errorf("nil input should be returned unchanged, got %v", out)
}
if out := ensureMetadataSpecCompliance([]byte{}); len(out) != 0 {
t.Errorf("empty input should be returned unchanged, got %v", out)
}
// A top-level JSON null literal must not panic on the slow path.
if out := ensureMetadataSpecCompliance([]byte("null")); string(out) != "null" {
t.Errorf("top-level null should be returned unchanged, got %s", string(out))
}
}
// Original iceberg-go key ordering must survive the backfill: appended
// sentinels go at the end without disturbing prior fields. A map-based
// remarshal would have sorted everything alphabetically.
func TestEnsureMetadataSpecCompliance_PreservesOriginalKeyOrder(t *testing.T) {
// Compact JSON, keys in struct-declared order from iceberg-go.
input := []byte(`{"format-version":2,"table-uuid":"82e3eec4-3aee-414f-a444-94c03c641d20","location":"s3://x/t","last-sequence-number":0,"last-updated-ms":1,"last-column-id":2,"current-schema-id":0,"default-spec-id":0,"last-partition-id":999,"default-sort-order-id":0}`)
out := ensureMetadataSpecCompliance(input)
// Prior keys keep their order, sentinels appended at the end.
want := `{"format-version":2,"table-uuid":"82e3eec4-3aee-414f-a444-94c03c641d20","location":"s3://x/t","last-sequence-number":0,"last-updated-ms":1,"last-column-id":2,"current-schema-id":0,"default-spec-id":0,"last-partition-id":999,"default-sort-order-id":0,"current-snapshot-id":-1,"snapshots":[],"snapshot-log":[],"metadata-log":[],"refs":{}}`
if string(out) != want {
t.Errorf("unexpected output\n got: %s\nwant: %s", string(out), want)
}
// Sanity: still valid JSON.
var parsed map[string]json.RawMessage
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
}
// An empty object {} must round-trip to a valid JSON object containing
// only the spec sentinels (no leading comma).
func TestEnsureMetadataSpecCompliance_EmptyObjectBackfilled(t *testing.T) {
out := ensureMetadataSpecCompliance([]byte(`{}`))
want := `{"current-snapshot-id":-1,"snapshots":[],"snapshot-log":[],"metadata-log":[],"refs":{}}`
if string(out) != want {
t.Errorf("unexpected output\n got: %s\nwant: %s", string(out), want)
}
}
// When all fields are already present, the original bytes must be returned
// untouched (no whitespace normalization, no key reordering).
func TestEnsureMetadataSpecCompliance_AllPresentReturnsSameBytes(t *testing.T) {
input := []byte("{\n \"current-snapshot-id\": 1,\n \"snapshots\": [],\n \"snapshot-log\": [],\n \"metadata-log\": [],\n \"refs\": {}\n}")
out := ensureMetadataSpecCompliance(input)
if string(out) != string(input) {
t.Errorf("expected original bytes returned unchanged\n got: %q\nwant: %q", string(out), string(input))
}
}

View File

@@ -0,0 +1,171 @@
package iceberg
import (
"context"
"fmt"
"os"
"path"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// metadataDirPath returns the filer directory holding a table's metadata files.
func metadataDirPath(bucketName, tablePath string) string {
dir := path.Join(s3tables.TablesPath, bucketName)
if tablePath != "" {
dir = path.Join(dir, tablePath)
}
return path.Join(dir, "metadata")
}
// saveMetadataFile saves the Iceberg metadata JSON file to the filer.
// It constructs the filer path from the S3 location components. With exclusive
// set the write fails with filer_pb.ErrEntryAlreadyExists rather than replacing
// a file that is already there: two commits racing off the same base pick the
// same v{N} name, and letting the second overwrite the first would leave the
// winner's catalog pointer aimed at the loser's metadata.
func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string, content []byte, exclusive bool) error {
return s.saveMetadataBlob(ctx, bucketName, tablePath, metadataFileName, content, "application/json", exclusive)
}
// saveMetadataBlob saves a file into the table's metadata directory.
func (s *Server) saveMetadataBlob(ctx context.Context, bucketName, tablePath, metadataFileName string, content []byte, mimeType string, exclusive bool) error {
// Create context with timeout for file operations
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
bucketsPath := s3tables.TablesPath
ensureDir := func(parent, name, errorContext string) error {
_, err := filer_pb.LookupEntry(opCtx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parent,
Name: name,
})
if err == nil {
return nil
}
if err != filer_pb.ErrNotFound {
return fmt.Errorf("lookup %s failed: %w", errorContext, err)
}
// If lookup fails with ErrNotFound, try to create the directory.
resp, createErr := client.CreateEntry(opCtx, &filer_pb.CreateEntryRequest{
Directory: parent,
Entry: &filer_pb.Entry{
Name: name,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(),
Crtime: time.Now().Unix(),
FileMode: uint32(0755 | os.ModeDir),
},
},
})
if createErr != nil {
return fmt.Errorf("failed to create %s: %w", errorContext, createErr)
}
if resp.ErrorCode != filer_pb.FilerError_OK && resp.ErrorCode != filer_pb.FilerError_ENTRY_ALREADY_EXISTS {
return fmt.Errorf("failed to create %s: %s", errorContext, resp.Error)
}
return nil
}
bucketDir := path.Join(bucketsPath, bucketName)
// 1. Ensure bucket directory exists: <bucketsPath>/<bucket>
if err := ensureDir(bucketsPath, bucketName, "bucket directory"); err != nil {
return err
}
// 2. Ensure table path exists under the bucket directory
tableDir := bucketDir
if tablePath != "" {
segments := strings.Split(tablePath, "/")
for _, segment := range segments {
if segment == "" {
continue
}
if err := ensureDir(tableDir, segment, "table directory"); err != nil {
return err
}
tableDir = path.Join(tableDir, segment)
}
}
// 3. Ensure metadata directory exists: <bucketsPath>/<bucket>/<tablePath>/metadata
metadataDir := path.Join(tableDir, "metadata")
if err := ensureDir(tableDir, "metadata", "metadata directory"); err != nil {
return err
}
// 4. Write the file
resp, err := client.CreateEntry(opCtx, &filer_pb.CreateEntryRequest{
Directory: metadataDir,
OExcl: exclusive,
Entry: &filer_pb.Entry{
Name: metadataFileName,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(),
Crtime: time.Now().Unix(),
FileMode: uint32(0644),
FileSize: uint64(len(content)),
},
Content: content,
Extended: map[string][]byte{
"Mime-Type": []byte(mimeType),
},
},
})
if err != nil {
return fmt.Errorf("failed to write metadata file: %w", err)
}
if resp.ErrorCode != filer_pb.FilerError_OK {
if sentinel := filer_pb.FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
return fmt.Errorf("failed to write metadata file: %w", sentinel)
}
return fmt.Errorf("failed to write metadata file: code=%v %s", resp.ErrorCode, resp.Error)
}
return nil
})
}
func (s *Server) deleteMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string) error {
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
metadataDir := metadataDirPath(bucketName, tablePath)
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer_pb.DoRemove(opCtx, client, metadataDir, metadataFileName, true, false, true, false, nil)
})
}
func (s *Server) loadMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string) ([]byte, error) {
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
metadataDir := metadataDirPath(bucketName, tablePath)
var content []byte
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(opCtx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: metadataDir,
Name: metadataFileName,
})
if err != nil {
return err
}
if resp == nil || resp.Entry == nil {
return fmt.Errorf("lookup returned nil entry for %s/%s", metadataDir, metadataFileName)
}
content = append([]byte(nil), resp.Entry.Content...)
return nil
})
if err != nil {
return nil, err
}
return content, nil
}

View File

@@ -0,0 +1,128 @@
package iceberg
import (
"context"
"errors"
"path"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
)
// stubFilerClient answers only the calls the metadata writers make, tracking
// which paths exist so exclusive creates can be exercised.
type stubFilerClient struct {
filer_pb.SeaweedFilerClient
entries map[string][]byte
}
func newStubFilerClient() *stubFilerClient {
return &stubFilerClient{entries: make(map[string][]byte)}
}
func (c *stubFilerClient) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
return fn(c)
}
func (c *stubFilerClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
key := path.Join(req.Directory, req.Name)
if _, ok := c.entries[key]; !ok {
return nil, filer_pb.ErrNotFound
}
return &filer_pb.LookupDirectoryEntryResponse{Entry: &filer_pb.Entry{Name: req.Name}}, nil
}
func (c *stubFilerClient) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
key := path.Join(req.Directory, req.Entry.Name)
if _, exists := c.entries[key]; exists && req.OExcl {
return &filer_pb.CreateEntryResponse{
Error: "entry already exists",
ErrorCode: filer_pb.FilerError_ENTRY_ALREADY_EXISTS,
}, nil
}
c.entries[key] = req.Entry.Content
return &filer_pb.CreateEntryResponse{}, nil
}
// Two commits racing off the same base metadata pick the same v{N} file name.
// The loser must be told, not allowed to replace the winner's metadata.
func TestSaveMetadataFileExclusiveRefusesToOverwrite(t *testing.T) {
client := newStubFilerClient()
s := &Server{filerClient: client}
ctx := context.Background()
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"winner":true}`), true); err != nil {
t.Fatalf("first write failed: %v", err)
}
err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"loser":true}`), true)
if !errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
t.Fatalf("second write err = %v, want ErrEntryAlreadyExists", err)
}
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
if stored != `{"winner":true}` {
t.Errorf("stored metadata = %s, want the first writer's content", stored)
}
}
// A metadata file left behind by an interrupted commit must not wedge the
// table: the next commit stages under a unique name rather than failing or
// overwriting, and the catalog pointer still decides the winner.
func TestStageCommitMetadataFallsBackToAUniqueName(t *testing.T) {
client := newStubFilerClient()
s := &Server{filerClient: client}
ctx := context.Background()
name, location, err := s.stageCommitMetadata(ctx, "bkt", "ns/tbl", "s3://bkt/ns/tbl", "v2.metadata.json", []byte(`{"orphan":true}`))
if err != nil {
t.Fatalf("first stage failed: %v", err)
}
if name != "v2.metadata.json" {
t.Errorf("first stage used %q, want the plain versioned name", name)
}
if location != "s3://bkt/ns/tbl/metadata/v2.metadata.json" {
t.Errorf("location = %q", location)
}
name, location, err = s.stageCommitMetadata(ctx, "bkt", "ns/tbl", "s3://bkt/ns/tbl", "v2.metadata.json", []byte(`{"second":true}`))
if err != nil {
t.Fatalf("second stage failed: %v", err)
}
if !strings.HasPrefix(name, "v2-") || !strings.HasSuffix(name, ".metadata.json") {
t.Errorf("second stage used %q, want a unique v2-* name", name)
}
if location != "s3://bkt/ns/tbl/metadata/"+name {
t.Errorf("location = %q, want it to match the staged name", location)
}
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
if stored != `{"orphan":true}` {
t.Errorf("the first file was overwritten: %s", stored)
}
if fallback := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), name)]); fallback != `{"second":true}` {
t.Errorf("fallback file holds %s, want the second writer's metadata", fallback)
}
}
// Paths that legitimately rewrite a file, such as manifest repair, keep the
// overwriting behaviour.
func TestSaveMetadataFileOverwrites(t *testing.T) {
client := newStubFilerClient()
s := &Server{filerClient: client}
ctx := context.Background()
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"first":true}`), false); err != nil {
t.Fatalf("first write failed: %v", err)
}
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"second":true}`), false); err != nil {
t.Fatalf("second write failed: %v", err)
}
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
if stored != `{"second":true}` {
t.Errorf("stored metadata = %s, want the second write", stored)
}
}

View File

@@ -0,0 +1,142 @@
package iceberg
import (
"encoding/json"
"slices"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
)
// nameMappingFromSchema derives the spec's field-id-to-names mapping from a
// table schema, mirroring Java's MappingUtil.create. List elements and map
// keys/values use the canonical "element"/"key"/"value" names.
func nameMappingFromSchema(schema *iceberg.Schema) iceberg.NameMapping {
return mappedFieldsOf(schema.Fields())
}
func mappedFieldsOf(fields []iceberg.NestedField) []iceberg.MappedField {
mapped := make([]iceberg.MappedField, 0, len(fields))
for _, field := range fields {
id := field.ID
mapped = append(mapped, iceberg.MappedField{
Names: []string{field.Name},
FieldID: &id,
Fields: mappedFieldsOfType(field.Type),
})
}
return mapped
}
func mappedFieldsOfType(typ iceberg.Type) []iceberg.MappedField {
switch t := typ.(type) {
case *iceberg.StructType:
return mappedFieldsOf(t.FieldList)
case *iceberg.ListType:
return mappedFieldsOf([]iceberg.NestedField{t.ElementField()})
case *iceberg.MapType:
return mappedFieldsOf([]iceberg.NestedField{t.KeyField(), t.ValueField()})
default:
return nil
}
}
// refreshDefaultNameMapping keeps schema.name-mapping.default in sync with
// the current schema across commits, so schema evolution does not strand
// readers of field-id-less data files on the creation-time mapping. The
// stored mapping is merged rather than replaced: per field-id, names already
// present are kept alongside the current schema name, so files written under
// a renamed column's old name (and user-added aliases) stay resolvable.
// Returns the (possibly patched) serialized metadata.
func refreshDefaultNameMapping(raw []byte, updated table.Metadata) []byte {
if updated == nil {
return raw
}
updatedSchema := updated.CurrentSchema()
if updatedSchema == nil || len(updatedSchema.Fields()) == 0 {
return raw
}
mapping := nameMappingFromSchema(updatedSchema)
existing := updated.Properties()[table.DefaultNameMappingKey]
if existing != "" {
var existingMapping iceberg.NameMapping
if err := json.Unmarshal([]byte(existing), &existingMapping); err != nil {
// Not a mapping we can merge; leave whatever the user stored.
return raw
}
mapping = mergeMappedFields(existingMapping, mapping)
}
wantJSON, err := json.Marshal(mapping)
if err != nil {
return raw
}
want := string(wantJSON)
if existing == want {
return raw
}
var metadata map[string]json.RawMessage
if err := json.Unmarshal(raw, &metadata); err != nil {
return raw
}
properties := map[string]json.RawMessage{}
if rawProperties, ok := metadata["properties"]; ok {
if err := json.Unmarshal(rawProperties, &properties); err != nil {
return raw
}
}
valueJSON, err := json.Marshal(want)
if err != nil {
return raw
}
properties[table.DefaultNameMappingKey] = valueJSON
propertiesJSON, err := json.Marshal(properties)
if err != nil {
return raw
}
metadata["properties"] = propertiesJSON
patched, err := json.Marshal(metadata)
if err != nil {
return raw
}
return patched
}
// mergeMappedFields folds the names of an existing mapping into the mapping
// derived from the current schema. Fields are matched by field-id per nesting
// level; entries whose ids left the schema are dropped, since current readers
// cannot project those fields anyway. A name can belong to only one field per
// level (Java readers reject ambiguous mappings), so a historical name that
// the current schema assigns to a different field-id stays with its new owner
// instead of being duplicated onto the old one — which also keeps mappings
// clean when a commit replaces the schema with reassigned field ids.
func mergeMappedFields(existing, derived []iceberg.MappedField) []iceberg.MappedField {
byID := make(map[int]*iceberg.MappedField, len(existing))
for i := range existing {
byID[existing[i].ID()] = &existing[i]
}
nameOwner := make(map[string]int)
for _, d := range derived {
for _, name := range d.Names {
nameOwner[name] = d.ID()
}
}
for i := range derived {
prior, ok := byID[derived[i].ID()]
if !ok {
continue
}
for _, name := range prior.Names {
if ownerID, taken := nameOwner[name]; taken && ownerID != derived[i].ID() {
continue
}
if !slices.Contains(derived[i].Names, name) {
derived[i].Names = append(derived[i].Names, name)
nameOwner[name] = derived[i].ID()
}
}
derived[i].Fields = mergeMappedFields(prior.Fields, derived[i].Fields)
}
return derived
}

View File

@@ -0,0 +1,213 @@
package iceberg
import (
"encoding/json"
"slices"
"testing"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
)
func TestNameMappingFromSchema(t *testing.T) {
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
iceberg.NestedField{ID: 3, Name: "tags", Type: &iceberg.ListType{
ElementID: 4, Element: iceberg.PrimitiveTypes.String,
}},
iceberg.NestedField{ID: 5, Name: "attrs", Type: &iceberg.MapType{
KeyID: 6, KeyType: iceberg.PrimitiveTypes.String,
ValueID: 7, ValueType: &iceberg.StructType{FieldList: []iceberg.NestedField{
{ID: 8, Name: "inner", Type: iceberg.PrimitiveTypes.Int32},
}},
}},
)
mapping := nameMappingFromSchema(schema)
got, err := json.Marshal(mapping)
if err != nil {
t.Fatalf("marshal mapping: %v", err)
}
want := `[{"names":["id"],"field-id":1},{"names":["label"],"field-id":2},` +
`{"names":["tags"],"field-id":3,"fields":[{"names":["element"],"field-id":4}]},` +
`{"names":["attrs"],"field-id":5,"fields":[{"names":["key"],"field-id":6},` +
`{"names":["value"],"field-id":7,"fields":[{"names":["inner"],"field-id":8}]}]}]`
if string(got) != want {
t.Fatalf("mapping = %s\nwant %s", got, want)
}
}
func TestRefreshDefaultNameMappingDropsReassignedNames(t *testing.T) {
// The stored mapping was derived from a schema whose ids were later
// reassigned (id<->name swapped). The merged mapping must follow the
// current schema exactly: duplicating names across ids makes Java readers
// reject the whole mapping.
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "name", Type: iceberg.PrimitiveTypes.String},
)
metadata, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil,
iceberg.Properties{table.DefaultNameMappingKey: `[{"names":["name"],"field-id":1},{"names":["id"],"field-id":2}]`})
if err != nil {
t.Fatalf("newTableMetadata: %v", err)
}
raw, err := json.Marshal(metadata)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
refreshed := refreshDefaultNameMapping(raw, metadata)
final, err := table.ParseMetadataBytes(refreshed)
if err != nil {
t.Fatalf("parse refreshed metadata: %v", err)
}
var mapping iceberg.NameMapping
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
t.Fatalf("parse refreshed mapping: %v", err)
}
seen := map[string]int{}
for _, field := range mapping {
for _, name := range field.Names {
if prior, dup := seen[name]; dup {
t.Fatalf("name %q mapped to both field %d and field %d: %s",
name, prior, field.ID(), final.Properties()[table.DefaultNameMappingKey])
}
seen[name] = field.ID()
}
}
if seen["id"] != 1 || seen["name"] != 2 {
t.Fatalf("current schema names must win: %s", final.Properties()[table.DefaultNameMappingKey])
}
}
// evolveMetadataSchema returns serialized metadata whose schema renamed
// "label" to "tag" and gained an "extra" column while the properties still
// carry the creation-time mapping, simulating a schema-evolution commit.
func evolveMetadataSchema(t *testing.T, base table.Metadata) []byte {
t.Helper()
raw, err := json.Marshal(base)
if err != nil {
t.Fatalf("marshal base metadata: %v", err)
}
evolved := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "tag", Type: iceberg.PrimitiveTypes.String},
iceberg.NestedField{ID: 3, Name: "extra", Type: iceberg.PrimitiveTypes.String},
)
schemaJSON, err := json.Marshal(evolved)
if err != nil {
t.Fatalf("marshal evolved schema: %v", err)
}
var metadata map[string]json.RawMessage
if err := json.Unmarshal(raw, &metadata); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
metadata["schemas"] = json.RawMessage("[" + string(schemaJSON) + "]")
edited, err := json.Marshal(metadata)
if err != nil {
t.Fatalf("marshal edited metadata: %v", err)
}
return edited
}
func TestRefreshDefaultNameMappingOnSchemaEvolution(t *testing.T) {
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
)
base, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil, nil)
if err != nil {
t.Fatalf("newTableMetadata: %v", err)
}
edited := evolveMetadataSchema(t, base)
updated, err := table.ParseMetadataBytes(edited)
if err != nil {
t.Fatalf("parse evolved metadata: %v", err)
}
refreshed := refreshDefaultNameMapping(edited, updated)
final, err := table.ParseMetadataBytes(refreshed)
if err != nil {
t.Fatalf("parse refreshed metadata: %v", err)
}
var mapping iceberg.NameMapping
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
t.Fatalf("parse refreshed mapping: %v", err)
}
if len(mapping) != 3 || mapping[2].ID() != 3 || mapping[2].Names[0] != "extra" {
t.Fatalf("mapping was not refreshed for the evolved schema: %s", final.Properties()[table.DefaultNameMappingKey])
}
// The renamed column keeps its historical name so files written under the
// old physical name stay resolvable.
if mapping[1].ID() != 2 || !slices.Contains(mapping[1].Names, "tag") || !slices.Contains(mapping[1].Names, "label") {
t.Fatalf("renamed column lost a name: %s", final.Properties()[table.DefaultNameMappingKey])
}
}
func TestRefreshDefaultNameMappingKeepsUserNames(t *testing.T) {
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
)
base, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil,
iceberg.Properties{table.DefaultNameMappingKey: `[{"names":["custom"],"field-id":1}]`})
if err != nil {
t.Fatalf("newTableMetadata: %v", err)
}
edited := evolveMetadataSchema(t, base)
updated, err := table.ParseMetadataBytes(edited)
if err != nil {
t.Fatalf("parse evolved metadata: %v", err)
}
refreshed := refreshDefaultNameMapping(edited, updated)
final, err := table.ParseMetadataBytes(refreshed)
if err != nil {
t.Fatalf("parse refreshed metadata: %v", err)
}
var mapping iceberg.NameMapping
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
t.Fatalf("parse refreshed mapping: %v", err)
}
// The user's alias survives the merge alongside the schema name.
if mapping[0].ID() != 1 || !slices.Contains(mapping[0].Names, "custom") || !slices.Contains(mapping[0].Names, "id") {
t.Fatalf("user alias was dropped: %s", final.Properties()[table.DefaultNameMappingKey])
}
}
func TestCreateTableSetsDefaultNameMapping(t *testing.T) {
// Request ids are deliberately non-sequential: the metadata constructor
// reassigns fresh ids, and the stamped mapping must follow the final
// schema, not the request.
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 7, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 3, Name: "label", Type: iceberg.PrimitiveTypes.String},
)
metadata, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil, nil)
if err != nil {
t.Fatalf("newTableMetadata: %v", err)
}
mappingJSON, ok := metadata.Properties()[table.DefaultNameMappingKey]
if !ok {
t.Fatalf("newTableMetadata did not set %s: %v", table.DefaultNameMappingKey, metadata.Properties())
}
var mapping iceberg.NameMapping
if err := json.Unmarshal([]byte(mappingJSON), &mapping); err != nil {
t.Fatalf("parse stamped mapping: %v", err)
}
finalSchema := metadata.CurrentSchema()
if len(mapping) != len(finalSchema.Fields()) {
t.Fatalf("mapping has %d entries, schema has %d", len(mapping), len(finalSchema.Fields()))
}
for i, field := range finalSchema.Fields() {
if mapping[i].ID() != field.ID || mapping[i].Names[0] != field.Name {
t.Fatalf("mapping entry %d = %v, want id %d name %s", i, mapping[i], field.ID, field.Name)
}
}
}

View File

@@ -0,0 +1,104 @@
package iceberg
import (
"fmt"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// validateRequestPath rejects Iceberg REST requests whose captured
// {prefix}/{namespace}/{table} mux vars would produce a parent-directory
// traversal when joined into a filer path. The iceberg router runs with
// SkipClean(true), so `..` survives routing; downstream path.Join calls
// (stageCreateMarkerDir, location builders, etc.) then collapse it and
// escape the table-bucket directory.
//
// {prefix} maps to a table-bucket name; {table} is a single path segment;
// {namespace} is unit-separator (0x1F) joined parts that get flattened into
// a single dotted name for the on-disk layout — each part is validated
// individually.
func validateRequestPath(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
// Use the comma-ok form so vars only checked when the matched route
// actually captures them; when captured, an empty value is itself a
// rejection because downstream path.Join would collapse it.
if prefix, ok := vars["prefix"]; ok {
if prefix == "" || !s3_constants.IsValidBucketName(prefix) {
writeError(w, http.StatusBadRequest, "BadRequest", "invalid prefix")
return
}
}
if table, ok := vars["table"]; ok {
if table == "" || !isValidNameSegment(table) {
writeError(w, http.StatusBadRequest, "BadRequest", "invalid table name")
return
}
}
if ns, ok := vars["namespace"]; ok {
if ns == "" {
writeError(w, http.StatusBadRequest, "BadRequest", "invalid namespace")
return
}
// Reject leading/trailing/consecutive unit separators so distinct
// inputs cannot collapse to the same parsed namespace via
// parseNamespace's empty-part filter.
for _, part := range strings.Split(ns, "\x1F") {
if part == "" || !isValidNameSegment(part) {
writeError(w, http.StatusBadRequest, "BadRequest", "invalid namespace")
return
}
}
}
next.ServeHTTP(w, r)
})
}
// isValidTablePath reports whether a "/"-separated table path (the part below
// the bucket) is free of segments that path.Join would collapse to escape the
// bucket directory. Empty segments (from leading/duplicate slashes) are
// ignored, but the path must resolve to at least one real segment so it does
// not collapse to the bucket-level metadata directory.
func isValidTablePath(tablePath string) bool {
hasSegment := false
for _, segment := range strings.Split(tablePath, "/") {
if segment == "" {
continue
}
if !isValidNameSegment(segment) {
return false
}
hasSegment = true
}
return hasSegment
}
// isValidNameSegment rejects a single path-segment value (bucket prefix slot,
// table name, or one namespace part) that would be unsafe to embed in a filer
// path: `.`, `..`, embedded slash/backslash, or NUL.
func isValidNameSegment(s string) bool {
if s == "" {
return true
}
if s == "." || s == ".." {
return false
}
return !strings.ContainsAny(s, "/\\\x00")
}
// confineMetadataLocation checks that a parsed s3 location stays within the
// authorized table bucket and rejects traversal segments that path.Join in
// saveMetadataBlob would collapse to escape it. The table path must resolve
// to at least one real segment so its metadata directory is table-specific.
func confineMetadataLocation(metadataBucket, metadataPath, bucketName string) error {
if metadataBucket != bucketName {
return fmt.Errorf("table location must be within bucket %s", bucketName)
}
if !isValidTablePath(metadataPath) {
return fmt.Errorf("invalid table location path")
}
return nil
}

View File

@@ -0,0 +1,139 @@
package iceberg
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
)
func TestIsValidTablePath(t *testing.T) {
tests := []struct {
tablePath string
want bool
}{
{"sales.orders", true},
{"ns/table", true},
{"ns//table", true},
{"", false},
{"/", false},
{"//", false},
{"../other", false},
{"ns/../../etc", false},
{"ns/./x", false},
{`ns\..\x`, false},
}
for _, tt := range tests {
if got := isValidTablePath(tt.tablePath); got != tt.want {
t.Errorf("isValidTablePath(%q) = %v, want %v", tt.tablePath, got, tt.want)
}
}
}
func TestValidateRequestPath_RejectsTraversal(t *testing.T) {
tests := []struct {
name string
rawPath string
wantCode int
}{
{"clean namespace+table passes", "/v1/namespaces/sales/tables/orders", http.StatusOK},
{"clean prefixed passes", "/v1/wh/namespaces/sales/tables/orders", http.StatusOK},
{"clean namespace only passes", "/v1/namespaces/sales", http.StatusOK},
// SkipClean(true) means raw `..` survives routing — these are the
// realistic traversal shapes the middleware must catch.
{"dotdot as prefix var rejected", "/v1/../namespaces/sales", http.StatusBadRequest},
{"dotdot as namespace var rejected", "/v1/namespaces/..", http.StatusBadRequest},
{"dotdot as namespace var prefixed rejected", "/v1/wh/namespaces/..", http.StatusBadRequest},
{"dotdot as table var rejected", "/v1/namespaces/sales/tables/..", http.StatusBadRequest},
{"dot as table var rejected", "/v1/namespaces/sales/tables/.", http.StatusBadRequest},
// Iceberg clients send the 0x1F unit separator percent-encoded; mux
// decodes it before the middleware sees the namespace var.
{"unit-sep namespace with dotdot part rejected", "/v1/namespaces/sales%1F..%1Fevil", http.StatusBadRequest},
{"leading unit-sep namespace rejected", "/v1/namespaces/%1Fsales", http.StatusBadRequest},
{"trailing unit-sep namespace rejected", "/v1/namespaces/sales%1F", http.StatusBadRequest},
{"consecutive unit-sep namespace rejected", "/v1/namespaces/sales%1F%1Fevil", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := mux.NewRouter().SkipClean(true)
router.Use(validateRequestPath)
handlerCalled := false
pass := func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
}
router.HandleFunc("/v1/namespaces/{namespace}", pass)
router.HandleFunc("/v1/namespaces/{namespace}/tables/{table}", pass)
router.HandleFunc("/v1/{prefix}/namespaces/{namespace}", pass)
router.HandleFunc("/v1/{prefix}/namespaces/{namespace}/tables/{table}", pass)
req := httptest.NewRequest(http.MethodGet, tt.rawPath, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tt.wantCode {
t.Fatalf("path %q: got status %d, want %d (body=%q)", tt.rawPath, rr.Code, tt.wantCode, rr.Body.String())
}
if tt.wantCode == http.StatusBadRequest && handlerCalled {
t.Fatalf("path %q: inner handler reached despite rejection", tt.rawPath)
}
})
}
}
// Defense-in-depth: if a future route or middleware ever leaves one of the
// captured vars empty, the middleware must still reject the request. The
// default mux regex won't normally allow this.
func TestValidateRequestPath_RejectsEmptyCapturedVars(t *testing.T) {
tests := []struct {
name string
vars map[string]string
}{
{"empty prefix", map[string]string{"prefix": "", "namespace": "ns"}},
{"empty table", map[string]string{"namespace": "ns", "table": ""}},
{"empty namespace", map[string]string{"namespace": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handlerCalled := false
h := validateRequestPath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
}))
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/", nil), tt.vars)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if handlerCalled {
t.Fatalf("vars %v: inner handler reached despite empty capture", tt.vars)
}
})
}
}
func TestIsValidNameSegment(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"empty ok", "", true},
{"plain", "orders", true},
{"with dot inside", "my.table", true},
{"hidden", ".hidden", true},
{"bare dot", ".", false},
{"bare dotdot", "..", false},
{"contains slash", "foo/bar", false},
{"contains backslash", "foo\\bar", false},
{"contains nul", "foo\x00bar", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isValidNameSegment(tt.input); got != tt.want {
t.Errorf("isValidNameSegment(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More