diff --git a/ROADMAP.md b/ROADMAP.md index dc411f1..f0861f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,20 +146,21 @@ Features Implemented: --- -## v0.16.0 - ACLs & Lifecycle +## v0.16.0 - ACLs & Lifecycle ✅ COMPLETED **Scope**: Advanced management **Size**: Medium (9 APIs, ~500 LOC) +**Released**: Nov 27, 2025 ACLs: -- [ ] PutObjectAcl -- [ ] GetObjectAcl -- [ ] PutBucketAcl -- [ ] GetBucketAcl +- [x] PutObjectAcl +- [x] GetObjectAcl +- [x] PutBucketAcl +- [x] GetBucketAcl Lifecycle: -- [ ] PutBucketLifecycle -- [ ] GetBucketLifecycle -- [ ] DeleteBucketLifecycle +- [x] PutBucketLifecycle +- [x] GetBucketLifecycle +- [x] DeleteBucketLifecycle **Why**: Grouped advanced features. Completes library API surface. diff --git a/acl_lifecycle_test.go b/acl_lifecycle_test.go new file mode 100644 index 0000000..890a51e --- /dev/null +++ b/acl_lifecycle_test.go @@ -0,0 +1,270 @@ +package simples3 + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" +) + +func TestBucketAcl(t *testing.T) { + s3 := setupTestS3(t) + bucketName := fmt.Sprintf("simples3-test-acl-%d", time.Now().UnixNano()) + + // Create bucket + _, err := s3.CreateBucket(CreateBucketInput{Bucket: bucketName}) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer s3.DeleteBucket(DeleteBucketInput{Bucket: bucketName}) + + // 1. Test Canned ACL + t.Run("CannedACL", func(t *testing.T) { + err := s3.PutBucketAcl(PutBucketAclInput{ + Bucket: bucketName, + CannedACL: "public-read", + }) + if err != nil { + if strings.HasPrefix(err.Error(), "status code: 501 Not Implemented") { + t.Skipf("Skipping CannedACL test: backend returned 501 Not Implemented") + } + t.Fatalf("PutBucketAcl (canned) failed: %v", err) + } + + // Verify + acl, err := s3.GetBucketAcl(bucketName) + if err != nil { + t.Fatalf("GetBucketAcl failed: %v", err) + } + + if len(acl.AccessControlList) == 0 { + t.Errorf("Expected ACL grants, got none") + } + t.Logf("Bucket ACL Owner: %s", acl.Owner.DisplayName) + }) + + // 2. Test Custom ACL (AccessControlPolicy) + // We need the owner ID first + acl, err := s3.GetBucketAcl(bucketName) + if err != nil { + t.Fatalf("Failed to get initial ACL: %v", err) + } + + t.Run("CustomACL", func(t *testing.T) { + // Create a policy that gives FULL_CONTROL to the owner + policy := &AccessControlPolicy{ + Owner: acl.Owner, + AccessControlList: []Grant{ + { + Grantee: Grantee{ + Type: "CanonicalUser", + ID: acl.Owner.ID, + DisplayName: acl.Owner.DisplayName, + }, + Permission: "FULL_CONTROL", + }, + }, + } + + err := s3.PutBucketAcl(PutBucketAclInput{ + Bucket: bucketName, + AccessControlPolicy: policy, + }) + if err != nil { + t.Fatalf("PutBucketAcl (custom) failed: %v", err) + } + + // Verify + newAcl, err := s3.GetBucketAcl(bucketName) + if err != nil { + t.Fatalf("GetBucketAcl failed: %v", err) + } + + if len(newAcl.AccessControlList) != 1 { + t.Errorf("Expected 1 grant, got %d", len(newAcl.AccessControlList)) + } + if newAcl.AccessControlList[0].Permission != "FULL_CONTROL" { + t.Errorf("Expected FULL_CONTROL, got %s", newAcl.AccessControlList[0].Permission) + } + }) +} + +func TestObjectAcl(t *testing.T) { + s3 := setupTestS3(t) + bucketName := fmt.Sprintf("simples3-test-obj-acl-%d", time.Now().UnixNano()) + objectKey := "test-object.txt" + + // Create bucket + _, err := s3.CreateBucket(CreateBucketInput{Bucket: bucketName}) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer s3.DeleteBucket(DeleteBucketInput{Bucket: bucketName}) + + // Upload object + _, err = s3.FileUpload(UploadInput{ + Bucket: bucketName, + ObjectKey: objectKey, + Body: bytes.NewReader([]byte("test content")), + ContentType: "text/plain", + }) + if err != nil { + t.Fatalf("Failed to upload object: %v", err) + } + defer s3.FileDelete(DeleteInput{Bucket: bucketName, ObjectKey: objectKey}) + + // 1. Test Canned ACL + t.Run("CannedACL", func(t *testing.T) { + err := s3.PutObjectAcl(PutObjectAclInput{ + Bucket: bucketName, + ObjectKey: objectKey, + CannedACL: "public-read", + }) + if err != nil { + if strings.HasPrefix(err.Error(), "status code: 501 Not Implemented") { + t.Skipf("Skipping CannedACL test: backend returned 501 Not Implemented") + } + t.Fatalf("PutObjectAcl (canned) failed: %v", err) + } + + // Verify + acl, err := s3.GetObjectAcl(GetObjectAclInput{ + Bucket: bucketName, + ObjectKey: objectKey, + }) + if err != nil { + t.Fatalf("GetObjectAcl failed: %v", err) + } + + if len(acl.AccessControlList) == 0 { + t.Errorf("Expected ACL grants, got none") + } + }) + + // 2. Test Custom ACL (AccessControlPolicy) + // We need the owner ID first + acl, err := s3.GetObjectAcl(GetObjectAclInput{ + Bucket: bucketName, + ObjectKey: objectKey, + }) + if err != nil { + t.Fatalf("Failed to get initial Object ACL: %v", err) + } + + t.Run("CustomACL", func(t *testing.T) { + // Create a policy that gives FULL_CONTROL to the owner + policy := &AccessControlPolicy{ + Owner: acl.Owner, + AccessControlList: []Grant{ + { + Grantee: Grantee{ + Type: "CanonicalUser", + ID: acl.Owner.ID, + DisplayName: acl.Owner.DisplayName, + }, + Permission: "FULL_CONTROL", + }, + }, + } + + err := s3.PutObjectAcl(PutObjectAclInput{ + Bucket: bucketName, + ObjectKey: objectKey, + AccessControlPolicy: policy, + }) + if err != nil { + t.Fatalf("PutObjectAcl (custom) failed: %v", err) + } + + // Verify + newAcl, err := s3.GetObjectAcl(GetObjectAclInput{ + Bucket: bucketName, + ObjectKey: objectKey, + }) + if err != nil { + t.Fatalf("GetObjectAcl failed: %v", err) + } + + if len(newAcl.AccessControlList) != 1 { + t.Errorf("Expected 1 grant, got %d", len(newAcl.AccessControlList)) + } + if newAcl.AccessControlList[0].Permission != "FULL_CONTROL" { + t.Errorf("Expected FULL_CONTROL, got %s", newAcl.AccessControlList[0].Permission) + } + }) +} + +func TestBucketLifecycle(t *testing.T) { + s3 := setupTestS3(t) + bucketName := fmt.Sprintf("simples3-test-lifecycle-%d", time.Now().UnixNano()) + + // Create bucket + _, err := s3.CreateBucket(CreateBucketInput{Bucket: bucketName}) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer s3.DeleteBucket(DeleteBucketInput{Bucket: bucketName}) + + // 1. Test PutLifecycle + t.Run("PutLifecycle", func(t *testing.T) { + config := &LifecycleConfiguration{ + Rules: []LifecycleRule{ + { + ID: "Rule1", + Status: "Enabled", + Filter: &LifecycleFilter{ + Prefix: "logs/", + }, + Expiration: &LifecycleExpiration{ + Days: 30, + }, + }, + }, + } + + err := s3.PutBucketLifecycle(PutBucketLifecycleInput{ + Bucket: bucketName, + Configuration: config, + }) + if err != nil { + t.Fatalf("PutBucketLifecycle failed: %v", err) + } + + // Verify + fetchedConfig, err := s3.GetBucketLifecycle(bucketName) + if err != nil { + t.Fatalf("GetBucketLifecycle failed: %v", err) + } + + if len(fetchedConfig.Rules) != 1 { + t.Errorf("Expected 1 rule, got %d", len(fetchedConfig.Rules)) + } + if fetchedConfig.Rules[0].ID != "Rule1" { + t.Errorf("Expected Rule1, got %s", fetchedConfig.Rules[0].ID) + } + if fetchedConfig.Rules[0].Filter == nil || fetchedConfig.Rules[0].Filter.Prefix != "logs/" { + t.Errorf("Expected prefix 'logs/', got %v", fetchedConfig.Rules[0].Filter) + } + }) + + // 2. Test DeleteLifecycle + t.Run("DeleteLifecycle", func(t *testing.T) { + err := s3.DeleteBucketLifecycle(DeleteBucketInput{ + Bucket: bucketName, + }) + if err != nil { + t.Fatalf("DeleteBucketLifecycle failed: %v", err) + } + + // Verify + _, err = s3.GetBucketLifecycle(bucketName) + if err == nil { + t.Errorf("Expected error after deleting lifecycle, got nil") + } else { + // Should verify it's a 404/NoSuchLifecycleConfiguration error + // The exact error message depends on implementation, but we expect an error + t.Logf("Got expected error: %v", err) + } + }) +} diff --git a/bucket.go b/bucket.go index 895b298..134f06e 100644 --- a/bucket.go +++ b/bucket.go @@ -435,3 +435,506 @@ func (s3 *S3) GetBucketVersioning(bucket string) (GetBucketVersioningOutput, err MfaDelete: config.MfaDelete, }, nil } + +// --- ACL Operations --- + +// AccessControlPolicy represents the Access Control List (ACL) for a bucket or object. +type AccessControlPolicy struct { + XMLName xml.Name `xml:"AccessControlPolicy"` + XMLNS string `xml:"xmlns,attr,omitempty"` + Owner Owner `xml:"Owner"` + AccessControlList []Grant `xml:"AccessControlList>Grant"` +} + +// Owner represents the owner of the bucket or object. +type Owner struct { + ID string `xml:"ID,omitempty"` + DisplayName string `xml:"DisplayName,omitempty"` +} + +// Grant represents a permission grant. +type Grant struct { + Grantee Grantee `xml:"Grantee"` + Permission string `xml:"Permission"` +} + +// Grantee represents the recipient of the permission grant. +type Grantee struct { + XMLNS string `xml:"xmlns:xsi,attr,omitempty"` + Type string `xml:"xsi:type,attr"` // CanonicalUser, AmazonCustomerByEmail, Group + ID string `xml:"ID,omitempty"` + DisplayName string `xml:"DisplayName,omitempty"` + URI string `xml:"URI,omitempty"` // For Group + EmailAddress string `xml:"EmailAddress,omitempty"` // For AmazonCustomerByEmail +} + +// PutBucketAclInput is passed to PutBucketAcl. +type PutBucketAclInput struct { + // Required: The name of the bucket + Bucket string + + // Optional: Canned ACL (private, public-read, public-read-write, authenticated-read) + // If CannedACL is set, AccessControlPolicy is ignored. + CannedACL string + + // Optional: Full Access Control Policy + AccessControlPolicy *AccessControlPolicy +} + +// PutBucketAcl sets the Access Control List (ACL) for a bucket. +// You can either use a CannedACL OR provide a full AccessControlPolicy. +func (s3 *S3) PutBucketAcl(input PutBucketAclInput) error { + // Validate input + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + if input.CannedACL == "" && input.AccessControlPolicy == nil { + return fmt.Errorf("either CannedACL or AccessControlPolicy must be provided") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build URL with ?acl query parameter + baseURL := s3.getURL(input.Bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + parsedURL.RawQuery = "acl" + + var req *http.Request + var bodyReader io.Reader + var contentMD5 string + var sha256Hash string + + if input.AccessControlPolicy != nil { + // Use custom ACL body + input.AccessControlPolicy.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/" + // Ensure namespace is set on Grantees if needed, though usually top level is enough. + // Some S3 implementations require xsi namespace. + for i := range input.AccessControlPolicy.AccessControlList { + input.AccessControlPolicy.AccessControlList[i].Grantee.XMLNS = "http://www.w3.org/2001/XMLSchema-instance" + } + + xmlBody, err := xml.Marshal(input.AccessControlPolicy) + if err != nil { + return err + } + + bodyReader = bytes.NewReader(xmlBody) + + // Calculate MD5 + md5Sum := md5.Sum(xmlBody) + contentMD5 = base64.StdEncoding.EncodeToString(md5Sum[:]) + + // Calculate SHA256 + h := sha256.New() + h.Write(xmlBody) + sha256Hash = fmt.Sprintf("%x", h.Sum(nil)) + + req, err = http.NewRequest(http.MethodPut, parsedURL.String(), bodyReader) + if err != nil { + return err + } + req.ContentLength = int64(len(xmlBody)) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-MD5", contentMD5) + req.Header.Set("x-amz-content-sha256", sha256Hash) + + } else { + // Use Canned ACL via header + req, err = http.NewRequest(http.MethodPut, parsedURL.String(), nil) + if err != nil { + return err + } + req.Header.Set("x-amz-acl", input.CannedACL) + // Empty body SHA256 + h := sha256.New() + sha256Hash = fmt.Sprintf("%x", h.Sum(nil)) + req.Header.Set("x-amz-content-sha256", sha256Hash) + } + + req.Header.Set("Host", req.URL.Host) + + // Sign the request + if err := s3.signRequest(req); err != nil { + return err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + return nil +} + +// GetBucketAcl gets the Access Control List (ACL) for a bucket. +func (s3 *S3) GetBucketAcl(bucket string) (AccessControlPolicy, error) { + // Validate input + if bucket == "" { + return AccessControlPolicy{}, fmt.Errorf("bucket name is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return AccessControlPolicy{}, err + } + + // Build URL with ?acl query parameter + baseURL := s3.getURL(bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return AccessControlPolicy{}, err + } + parsedURL.RawQuery = "acl" + + // Create GET request + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return AccessControlPolicy{}, err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return AccessControlPolicy{}, err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return AccessControlPolicy{}, err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return AccessControlPolicy{}, err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return AccessControlPolicy{}, fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + // Parse XML response + var policy AccessControlPolicy + if err := xml.Unmarshal(body, &policy); err != nil { + return AccessControlPolicy{}, err + } + + return policy, nil +} + +// --- Lifecycle Operations --- + +// LifecycleConfiguration represents the lifecycle configuration for a bucket. +type LifecycleConfiguration struct { + XMLName xml.Name `xml:"LifecycleConfiguration"` + XMLNS string `xml:"xmlns,attr,omitempty"` + Rules []LifecycleRule `xml:"Rule"` +} + +// LifecycleRule represents a single lifecycle rule. +type LifecycleRule struct { + ID string `xml:"ID,omitempty"` + Status string `xml:"Status"` // Enabled or Disabled + Filter *LifecycleFilter `xml:"Filter,omitempty"` + // Legacy Prefix support (S3 supports both, but prefer Filter for V2) + // If Prefix is provided here, do not use Filter. + Prefix *string `xml:"Prefix,omitempty"` + Expiration *LifecycleExpiration `xml:"Expiration,omitempty"` + Transitions []LifecycleTransition `xml:"Transition,omitempty"` + NoncurrentVersionExpiration *LifecycleNoncurrentVersionExpiration `xml:"NoncurrentVersionExpiration,omitempty"` + NoncurrentVersionTransitions []LifecycleNoncurrentVersionTransition `xml:"NoncurrentVersionTransition,omitempty"` + AbortIncompleteMultipartUpload *LifecycleAbortIncompleteMultipartUpload `xml:"AbortIncompleteMultipartUpload,omitempty"` +} + +// LifecycleFilter represents the filter for a lifecycle rule. +type LifecycleFilter struct { + Prefix string `xml:"Prefix,omitempty"` + Tag *Tag `xml:"Tag,omitempty"` + And *struct { + Prefix string `xml:"Prefix,omitempty"` + Tags []Tag `xml:"Tag,omitempty"` + } `xml:"And,omitempty"` +} + +// LifecycleExpiration represents the expiration action. +type LifecycleExpiration struct { + Date string `xml:"Date,omitempty"` // ISO 8601 format + Days int `xml:"Days,omitempty"` + ExpiredObjectDeleteMarker bool `xml:"ExpiredObjectDeleteMarker,omitempty"` +} + +// LifecycleTransition represents a transition action. +type LifecycleTransition struct { + Date string `xml:"Date,omitempty"` + Days int `xml:"Days,omitempty"` + StorageClass string `xml:"StorageClass"` +} + +// LifecycleNoncurrentVersionExpiration represents expiration for noncurrent versions. +type LifecycleNoncurrentVersionExpiration struct { + NoncurrentDays int `xml:"NoncurrentDays"` +} + +// LifecycleNoncurrentVersionTransition represents transition for noncurrent versions. +type LifecycleNoncurrentVersionTransition struct { + NoncurrentDays int `xml:"NoncurrentDays"` + StorageClass string `xml:"StorageClass"` +} + +// LifecycleAbortIncompleteMultipartUpload represents abort action for incomplete uploads. +type LifecycleAbortIncompleteMultipartUpload struct { + DaysAfterInitiation int `xml:"DaysAfterInitiation"` +} + +// PutBucketLifecycleInput is passed to PutBucketLifecycle. +type PutBucketLifecycleInput struct { + // Required: The name of the bucket + Bucket string + + // Required: The lifecycle configuration + Configuration *LifecycleConfiguration +} + +// PutBucketLifecycle sets the lifecycle configuration for a bucket. +func (s3 *S3) PutBucketLifecycle(input PutBucketLifecycleInput) error { + // Validate input + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + if input.Configuration == nil || len(input.Configuration.Rules) == 0 { + return fmt.Errorf("lifecycle configuration with at least one rule is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build URL with ?lifecycle query parameter + baseURL := s3.getURL(input.Bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + parsedURL.RawQuery = "lifecycle" + + // Set XML Namespace + input.Configuration.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/" + + xmlBody, err := xml.Marshal(input.Configuration) + if err != nil { + return err + } + + // Calculate MD5 + md5Hash := md5.Sum(xmlBody) + contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:]) + + // Calculate SHA256 + h := sha256.New() + h.Write(xmlBody) + sha256Hash := fmt.Sprintf("%x", h.Sum(nil)) + + // Create PUT request + req, err := http.NewRequest(http.MethodPut, parsedURL.String(), bytes.NewReader(xmlBody)) + if err != nil { + return err + } + + req.ContentLength = int64(len(xmlBody)) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-MD5", contentMD5) + req.Header.Set("x-amz-content-sha256", sha256Hash) + req.Header.Set("Host", req.URL.Host) + + // Sign the request + if err := s3.signRequest(req); err != nil { + return err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + return nil +} + +// GetBucketLifecycle gets the lifecycle configuration for a bucket. +func (s3 *S3) GetBucketLifecycle(bucket string) (LifecycleConfiguration, error) { + // Validate input + if bucket == "" { + return LifecycleConfiguration{}, fmt.Errorf("bucket name is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return LifecycleConfiguration{}, err + } + + // Build URL with ?lifecycle query parameter + baseURL := s3.getURL(bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return LifecycleConfiguration{}, err + } + parsedURL.RawQuery = "lifecycle" + + // Create GET request + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return LifecycleConfiguration{}, err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return LifecycleConfiguration{}, err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return LifecycleConfiguration{}, err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return LifecycleConfiguration{}, err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + // S3 returns 404 if no lifecycle configuration exists + if res.StatusCode == http.StatusNotFound { + // Check if it's actually "NoSuchLifecycleConfiguration" + if strings.Contains(string(body), "NoSuchLifecycleConfiguration") { + return LifecycleConfiguration{}, fmt.Errorf("no lifecycle configuration found") + } + } + return LifecycleConfiguration{}, fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + // Parse XML response + var config LifecycleConfiguration + if err := xml.Unmarshal(body, &config); err != nil { + return LifecycleConfiguration{}, err + } + + return config, nil +} + +// DeleteBucketLifecycle deletes the lifecycle configuration for a bucket. +func (s3 *S3) DeleteBucketLifecycle(input DeleteBucketInput) error { + // Reuse DeleteBucketInput since it just needs the bucket name + + // Validate input + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build URL with ?lifecycle query parameter + baseURL := s3.getURL(input.Bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + parsedURL.RawQuery = "lifecycle" + + // Create DELETE request + req, err := http.NewRequest(http.MethodDelete, parsedURL.String(), nil) + if err != nil { + return err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + // Handle non-success status codes + // AWS returns 204 No Content on successful deletion + if res.StatusCode != http.StatusNoContent { + return fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + return nil +} diff --git a/object.go b/object.go index 9a81a8e..130905e 100644 --- a/object.go +++ b/object.go @@ -777,3 +777,223 @@ func (s3 *S3) DeleteObjects(input DeleteObjectsInput) (DeleteObjectsOutput, erro Errors: result.Errors, }, nil } + +// --- ACL Operations --- + +// PutObjectAclInput is passed to PutObjectAcl. +type PutObjectAclInput struct { + // Required: The name of the bucket + Bucket string + + // Required: The object key + ObjectKey string + + // Optional: Version ID of the object + VersionId string + + // Optional: Canned ACL (private, public-read, public-read-write, authenticated-read) + // If CannedACL is set, AccessControlPolicy is ignored. + CannedACL string + + // Optional: Full Access Control Policy + AccessControlPolicy *AccessControlPolicy +} + +// PutObjectAcl sets the Access Control List (ACL) for an object. +// You can either use a CannedACL OR provide a full AccessControlPolicy. +func (s3 *S3) PutObjectAcl(input PutObjectAclInput) error { + // Validate input + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + if input.ObjectKey == "" { + return fmt.Errorf("object key is required") + } + if input.CannedACL == "" && input.AccessControlPolicy == nil { + return fmt.Errorf("either CannedACL or AccessControlPolicy must be provided") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build URL with ?acl query parameter + baseURL := s3.getURL(input.Bucket, input.ObjectKey) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + q := parsedURL.Query() + q.Set("acl", "") + if input.VersionId != "" { + q.Set("versionId", input.VersionId) + } + parsedURL.RawQuery = q.Encode() + + var req *http.Request + var bodyReader io.Reader + var contentMD5 string + var sha256Hash string + + if input.AccessControlPolicy != nil { + // Use custom ACL body + input.AccessControlPolicy.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/" + // Ensure namespace is set on Grantees if needed + for i := range input.AccessControlPolicy.AccessControlList { + input.AccessControlPolicy.AccessControlList[i].Grantee.XMLNS = "http://www.w3.org/2001/XMLSchema-instance" + } + + xmlBody, err := xml.Marshal(input.AccessControlPolicy) + if err != nil { + return err + } + + bodyReader = bytes.NewReader(xmlBody) + + // Calculate MD5 + md5Sum := md5.Sum(xmlBody) + contentMD5 = base64.StdEncoding.EncodeToString(md5Sum[:]) + + // Calculate SHA256 + h := sha256.New() + h.Write(xmlBody) + sha256Hash = fmt.Sprintf("%x", h.Sum(nil)) + + req, err = http.NewRequest(http.MethodPut, parsedURL.String(), bodyReader) + if err != nil { + return err + } + req.ContentLength = int64(len(xmlBody)) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-MD5", contentMD5) + req.Header.Set("x-amz-content-sha256", sha256Hash) + + } else { + // Use Canned ACL via header + req, err = http.NewRequest(http.MethodPut, parsedURL.String(), nil) + if err != nil { + return err + } + req.Header.Set("x-amz-acl", input.CannedACL) + // Empty body SHA256 + h := sha256.New() + sha256Hash = fmt.Sprintf("%x", h.Sum(nil)) + req.Header.Set("x-amz-content-sha256", sha256Hash) + } + + req.Header.Set("Host", req.URL.Host) + + // Sign the request + if err := s3.signRequest(req); err != nil { + return err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + return nil +} + +// GetObjectAclInput is passed to GetObjectAcl. +type GetObjectAclInput struct { + // Required: The name of the bucket + Bucket string + + // Required: The object key + ObjectKey string + + // Optional: Version ID of the object + VersionId string +} + +// GetObjectAcl gets the Access Control List (ACL) for an object. +func (s3 *S3) GetObjectAcl(input GetObjectAclInput) (AccessControlPolicy, error) { + // Validate input + if input.Bucket == "" { + return AccessControlPolicy{}, fmt.Errorf("bucket name is required") + } + if input.ObjectKey == "" { + return AccessControlPolicy{}, fmt.Errorf("object key is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return AccessControlPolicy{}, err + } + + // Build URL with ?acl query parameter + baseURL := s3.getURL(input.Bucket, input.ObjectKey) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return AccessControlPolicy{}, err + } + q := parsedURL.Query() + q.Set("acl", "") + if input.VersionId != "" { + q.Set("versionId", input.VersionId) + } + parsedURL.RawQuery = q.Encode() + + // Create GET request + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return AccessControlPolicy{}, err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return AccessControlPolicy{}, err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return AccessControlPolicy{}, err + } + + defer func() { + res.Body.Close() + io.Copy(io.Discard, res.Body) + }() + + // Read response body + body, err := io.ReadAll(res.Body) + if err != nil { + return AccessControlPolicy{}, err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return AccessControlPolicy{}, fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + // Parse XML response + var policy AccessControlPolicy + if err := xml.Unmarshal(body, &policy); err != nil { + return AccessControlPolicy{}, err + } + + return policy, nil +} diff --git a/versioning_test.go b/versioning_test.go index 8801009..0b1fc67 100644 --- a/versioning_test.go +++ b/versioning_test.go @@ -17,7 +17,7 @@ func TestVersioning(t *testing.T) { if err != nil { t.Fatalf("CreateBucket failed: %v", err) } - + // Cleanup function defer func() { // List all versions and delete markers to clean up @@ -52,25 +52,25 @@ func TestVersioning(t *testing.T) { } key := "test-object.txt" - + // 3. Upload v1 _, err = s3.FilePut(UploadInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, - Body: strings.NewReader("v1"), + Body: strings.NewReader("v1"), }) if err != nil { t.Fatalf("FilePut v1 failed: %v", err) } - + // Sleep briefly to ensure timestamp difference (MinIO sometimes has granularity issues) time.Sleep(1 * time.Second) // 4. Upload v2 _, err = s3.FilePut(UploadInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, - Body: strings.NewReader("v2"), + Body: strings.NewReader("v2"), }) if err != nil { t.Fatalf("FilePut v2 failed: %v", err) @@ -106,7 +106,7 @@ func TestVersioning(t *testing.T) { // 6. Download v1 specific version rc, err := s3.FileDownload(DownloadInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, VersionId: v1Id, }) @@ -121,7 +121,7 @@ func TestVersioning(t *testing.T) { // 7. Download v2 specific version rc, err = s3.FileDownload(DownloadInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, VersionId: v2Id, }) @@ -133,10 +133,10 @@ func TestVersioning(t *testing.T) { if string(content) != "v2" { t.Errorf("Expected v2 content 'v2', got '%s'", string(content)) } - + // 8. Get Details for v1 details, err := s3.FileDetails(DetailsInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, VersionId: v1Id, }) @@ -149,7 +149,7 @@ func TestVersioning(t *testing.T) { // 9. Delete v2 (latest) err = s3.FileDelete(DeleteInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, VersionId: v2Id, }) @@ -159,7 +159,7 @@ func TestVersioning(t *testing.T) { // 10. Verify current object is v1 rc, err = s3.FileDownload(DownloadInput{ - Bucket: bucket, + Bucket: bucket, ObjectKey: key, }) if err != nil {