diff --git a/README.md b/README.md index 93cdaaf..96d2f95 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ using AWS Signature Version 4. - **Simple List API** with pagination, prefix filtering, and delimiter grouping - **Iterator-based ListAll** for memory-efficient large bucket iteration (Go 1.23+) - **Presigned URL generation** for secure browser uploads/downloads (including multipart) +- **Object Versioning** - Enable versioning, list versions, and access specific object versions - **IAM credential support** for EC2 instances - **Comprehensive test coverage** - **Zero dependencies** - uses only Go standard library @@ -417,6 +418,73 @@ Note: Tag support during upload varies by operation: - **FileUpload** (POST): ⚠️ AWS S3 supported, MinIO limited (does not support tags in POST policy) - **CopyObject**: ✅ Full support (handles MinIO/R2 signature quirks automatically) +### Object Versioning + +Enable and manage object versions to protect against accidental deletion or overwrite. + +#### Enable Versioning + +```go +err := s3.PutBucketVersioning(simples3.PutBucketVersioningInput{ + Bucket: "my-bucket", + Status: "Enabled", // or "Suspended" +}) +if err != nil { + log.Fatal(err) +} + +// Check status +config, err := s3.GetBucketVersioning("my-bucket") +fmt.Printf("Versioning status: %s\n", config.Status) +``` + +#### List Object Versions + +List all versions of objects in a bucket: + +```go +result, err := s3.ListVersions(simples3.ListVersionsInput{ + Bucket: "my-bucket", + Prefix: "important-file.txt", +}) +if err != nil { + log.Fatal(err) +} + +for _, version := range result.Versions { + fmt.Printf("Key: %s, VersionId: %s, IsLatest: %v\n", + version.Key, version.VersionId, version.IsLatest) +} +``` + +#### Download Specific Version + +Retrieve a specific version of an object: + +```go +file, err := s3.FileDownload(simples3.DownloadInput{ + Bucket: "my-bucket", + ObjectKey: "my-file.txt", + VersionId: "v1-version-id", +}) +if err != nil { + log.Fatal(err) +} +// Use file (io.ReadCloser) normally +``` + +#### Delete Specific Version + +Delete a specific version of an object: + +```go +err := s3.FileDelete(simples3.DeleteInput{ + Bucket: "my-bucket", + ObjectKey: "my-file.txt", + VersionId: "v1-version-id", +}) +``` + ### Multipart Upload For large files (>100MB), use multipart upload for better performance, resumability, and parallel uploads. diff --git a/ROADMAP.md b/ROADMAP.md index 502c93b..dc411f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,15 +131,16 @@ Features Implemented: --- -## v0.15.0 - Object Versioning +## v0.15.0 - Object Versioning ✅ COMPLETED **Scope**: Version control **Size**: Medium (5 APIs, ~300 LOC) +**Released**: Nov 27, 2025 -- [ ] PutBucketVersioning -- [ ] GetBucketVersioning -- [ ] ListObjectVersions -- [ ] GetObjectVersion -- [ ] DeleteObjectVersion (with version ID) +- [x] PutBucketVersioning +- [x] GetBucketVersioning +- [x] ListObjectVersions +- [x] GetObjectVersion (via FileDownload) +- [x] DeleteObjectVersion (via FileDelete) **Why**: Important for data safety. More complex due to version handling. diff --git a/bucket.go b/bucket.go index e24d01d..895b298 100644 --- a/bucket.go +++ b/bucket.go @@ -4,10 +4,15 @@ package simples3 import ( + "bytes" + "crypto/md5" + "crypto/sha256" + "encoding/base64" "encoding/xml" "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -244,3 +249,189 @@ func (s3 *S3) DeleteBucket(input DeleteBucketInput) error { return nil } + +// VersioningConfiguration represents the versioning configuration of a bucket. +type VersioningConfiguration struct { + Status string `xml:"Status"` // Enabled or Suspended + MfaDelete string `xml:"MfaDelete,omitempty"` // Enabled or Disabled +} + +// PutBucketVersioningInput is passed to PutBucketVersioning as a parameter. +type PutBucketVersioningInput struct { + // Required: The name of the bucket + Bucket string + + // Required: Versioning status (Enabled or Suspended) + Status string + + // Optional: MFA Delete status (Enabled or Disabled) + // Note: Requires MFA authentication in the request, which is not yet supported + MfaDelete string +} + +// GetBucketVersioningOutput is returned by GetBucketVersioning. +type GetBucketVersioningOutput struct { + Status string + MfaDelete string +} + +// versioningConfigurationXML is the internal type for XML marshaling. +type versioningConfigurationXML struct { + XMLName xml.Name `xml:"VersioningConfiguration"` + XMLNS string `xml:"xmlns,attr"` + Status string `xml:"Status"` + MfaDelete string `xml:"MfaDelete,omitempty"` +} + +// PutBucketVersioning sets the versioning configuration for a bucket. +func (s3 *S3) PutBucketVersioning(input PutBucketVersioningInput) error { + // Validate input + if input.Bucket == "" { + return fmt.Errorf("bucket name is required") + } + if input.Status != "Enabled" && input.Status != "Suspended" { + return fmt.Errorf("status must be 'Enabled' or 'Suspended'") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return err + } + + // Build XML request body + config := versioningConfigurationXML{ + XMLNS: "http://s3.amazonaws.com/doc/2006-03-01/", + Status: input.Status, + MfaDelete: input.MfaDelete, + } + + xmlBody, err := xml.Marshal(config) + if err != nil { + return err + } + + // Calculate Content-MD5 + md5Hash := md5.Sum(xmlBody) + contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:]) + + // Build URL with ?versioning query parameter + baseURL := s3.getURL(input.Bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + parsedURL.RawQuery = "versioning" + + // Create PUT request + req, err := http.NewRequest(http.MethodPut, parsedURL.String(), bytes.NewReader(xmlBody)) + if err != nil { + return err + } + + // Set headers + req.ContentLength = int64(len(xmlBody)) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-MD5", contentMD5) + + // Calculate SHA256 + h := sha256.New() + h.Write(xmlBody) + req.Header.Set("x-amz-content-sha256", fmt.Sprintf("%x", h.Sum(nil))) + 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 +} + +// GetBucketVersioning gets the versioning configuration for a bucket. +func (s3 *S3) GetBucketVersioning(bucket string) (GetBucketVersioningOutput, error) { + // Validate input + if bucket == "" { + return GetBucketVersioningOutput{}, fmt.Errorf("bucket name is required") + } + + // Renew IAM token if needed + if err := s3.renewIAMToken(); err != nil { + return GetBucketVersioningOutput{}, err + } + + // Build URL with ?versioning query parameter + baseURL := s3.getURL(bucket) + parsedURL, err := url.Parse(baseURL) + if err != nil { + return GetBucketVersioningOutput{}, err + } + parsedURL.RawQuery = "versioning" + + // Create GET request + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return GetBucketVersioningOutput{}, err + } + + // Sign the request + if err := s3.signRequest(req); err != nil { + return GetBucketVersioningOutput{}, err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return GetBucketVersioningOutput{}, 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 GetBucketVersioningOutput{}, err + } + + // Handle non-OK status codes + if res.StatusCode != http.StatusOK { + return GetBucketVersioningOutput{}, fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + // Parse XML response + var config VersioningConfiguration + if err := xml.Unmarshal(body, &config); err != nil { + return GetBucketVersioningOutput{}, err + } + + return GetBucketVersioningOutput{ + Status: config.Status, + MfaDelete: config.MfaDelete, + }, nil +} diff --git a/list.go b/list.go index 0e691da..b3a2b25 100644 --- a/list.go +++ b/list.go @@ -258,3 +258,196 @@ func (s3 *S3) ListAll(input ListInput) (iter.Seq[Object], func() error) { return seq, finish } + +// ListVersionsInput is passed to ListVersions as a parameter. +type ListVersionsInput struct { + // Required: The name of the bucket to list versions from + Bucket string + + // Optional: A delimiter to group objects by (commonly "/") + Delimiter string + + // Optional: Only list objects starting with this prefix + Prefix string + + // Optional: Maximum number of objects to return + MaxKeys int64 + + // Optional: Key to start listing after (used for pagination) + KeyMarker string + + // Optional: Version ID to start listing after (used for pagination) + VersionIdMarker string +} + +// ObjectVersion represents a version of an object. +type ObjectVersion struct { + ETag string `xml:"ETag"` + IsLatest bool `xml:"IsLatest"` + Key string `xml:"Key"` + LastModified string `xml:"LastModified"` + Size int64 `xml:"Size"` + StorageClass string `xml:"StorageClass"` + VersionId string `xml:"VersionId"` + Owner struct { + ID string `xml:"ID"` + DisplayName string `xml:"DisplayName"` + } `xml:"Owner"` +} + +// DeleteMarker represents a delete marker for an object. +type DeleteMarker struct { + IsLatest bool `xml:"IsLatest"` + Key string `xml:"Key"` + LastModified string `xml:"LastModified"` + VersionId string `xml:"VersionId"` + Owner struct { + ID string `xml:"ID"` + DisplayName string `xml:"DisplayName"` + } `xml:"Owner"` +} + +// ListVersionsResponse is returned by ListVersions. +type ListVersionsResponse struct { + Name string `xml:"Name"` + Prefix string `xml:"Prefix"` + KeyMarker string `xml:"KeyMarker"` + VersionIdMarker string `xml:"VersionIdMarker"` + MaxKeys int64 `xml:"MaxKeys"` + Delimiter string `xml:"Delimiter"` + IsTruncated bool `xml:"IsTruncated"` + NextKeyMarker string `xml:"NextKeyMarker"` + NextVersionIdMarker string `xml:"NextVersionIdMarker"` + Versions []ObjectVersion `xml:"Version"` + DeleteMarkers []DeleteMarker `xml:"DeleteMarker"` + CommonPrefixes []string +} + +// ListVersions lists object versions in a bucket. +func (s3 *S3) ListVersions(input ListVersionsInput) (ListVersionsResponse, error) { + // Input validation + if input.Bucket == "" { + return ListVersionsResponse{}, fmt.Errorf("bucket name cannot be empty") + } + if input.MaxKeys < 0 { + return ListVersionsResponse{}, fmt.Errorf("MaxKeys cannot be negative") + } + if input.MaxKeys > 1000 { + return ListVersionsResponse{}, fmt.Errorf("MaxKeys cannot exceed 1000") + } + + // Build query parameters + query := url.Values{} + query.Set("versions", "") // Enables version listing + + // Add optional parameters if they exist + if input.Delimiter != "" { + query.Set("delimiter", input.Delimiter) + } + if input.Prefix != "" { + query.Set("prefix", input.Prefix) + } + if input.MaxKeys > 0 { + query.Set("max-keys", fmt.Sprintf("%d", input.MaxKeys)) + } + if input.KeyMarker != "" { + query.Set("key-marker", input.KeyMarker) + } + if input.VersionIdMarker != "" { + query.Set("version-id-marker", input.VersionIdMarker) + } + + // Build base URL + baseURL := s3.getURL(input.Bucket) + + // Parse URL and add query parameters + parsedURL, err := url.Parse(baseURL) + if err != nil { + return ListVersionsResponse{}, err + } + parsedURL.RawQuery = query.Encode() + + // Create HTTP request + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return ListVersionsResponse{}, err + } + + // Apply AWS V4 signing + if err := s3.renewIAMToken(); err != nil { + return ListVersionsResponse{}, err + } + if err := s3.signRequest(req); err != nil { + return ListVersionsResponse{}, err + } + + // Execute request + client := s3.getClient() + res, err := client.Do(req) + if err != nil { + return ListVersionsResponse{}, err + } + + // Close response body when done + defer res.Body.Close() + + // Read response body once + body, err := io.ReadAll(res.Body) + if err != nil { + return ListVersionsResponse{}, err + } + + // Handle response status codes + if res.StatusCode != http.StatusOK { + var s3Err S3Error + if xmlErr := xml.Unmarshal(body, &s3Err); xmlErr == nil { + return ListVersionsResponse{}, fmt.Errorf("S3 Error: %s - %s", s3Err.Code, s3Err.Message) + } + return ListVersionsResponse{}, fmt.Errorf("status code: %s: %s", res.Status, string(body)) + } + + // Parse XML response using internal struct for CommonPrefixes handling + var xmlResult struct { + XMLName xml.Name `xml:"ListVersionsResult"` + Name string `xml:"Name"` + Prefix string `xml:"Prefix"` + KeyMarker string `xml:"KeyMarker"` + VersionIdMarker string `xml:"VersionIdMarker"` + MaxKeys int64 `xml:"MaxKeys"` + Delimiter string `xml:"Delimiter"` + IsTruncated bool `xml:"IsTruncated"` + NextKeyMarker string `xml:"NextKeyMarker"` + NextVersionIdMarker string `xml:"NextVersionIdMarker"` + Versions []ObjectVersion `xml:"Version"` + DeleteMarkers []DeleteMarker `xml:"DeleteMarker"` + CommonPrefixes []struct { + Prefix string `xml:"Prefix"` + } `xml:"CommonPrefixes"` + } + + if err := xml.Unmarshal(body, &xmlResult); err != nil { + return ListVersionsResponse{}, err + } + + // Convert to simple response format + response := ListVersionsResponse{ + Name: xmlResult.Name, + Prefix: xmlResult.Prefix, + KeyMarker: xmlResult.KeyMarker, + VersionIdMarker: xmlResult.VersionIdMarker, + MaxKeys: xmlResult.MaxKeys, + Delimiter: xmlResult.Delimiter, + IsTruncated: xmlResult.IsTruncated, + NextKeyMarker: xmlResult.NextKeyMarker, + NextVersionIdMarker: xmlResult.NextVersionIdMarker, + Versions: xmlResult.Versions, + DeleteMarkers: xmlResult.DeleteMarkers, + } + + // Convert common prefixes + for _, prefix := range xmlResult.CommonPrefixes { + response.CommonPrefixes = append(response.CommonPrefixes, prefix.Prefix) + } + + return response, nil +} diff --git a/object.go b/object.go index e04f493..9a81a8e 100644 --- a/object.go +++ b/object.go @@ -21,12 +21,14 @@ import ( type DownloadInput struct { Bucket string ObjectKey string + VersionId string // Optional: Version ID of the object to download } // DetailsInput is passed to FileDetails as a parameter. type DetailsInput struct { Bucket string ObjectKey string + VersionId string // Optional: Version ID of the object to retrieve details for } // DetailsResponse is returned by FileDetails. @@ -93,13 +95,27 @@ type PutResponse struct { type DeleteInput struct { Bucket string ObjectKey string + VersionId string // Optional: Version ID of the object to delete } // FileDownload makes a GET call and returns a io.ReadCloser. // After reading the response body, ensure closing the response. func (s3 *S3) FileDownload(u DownloadInput) (io.ReadCloser, error) { + urlStr := s3.getURL(u.Bucket, u.ObjectKey) + + if u.VersionId != "" { + parsed, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + q := parsed.Query() + q.Set("versionId", u.VersionId) + parsed.RawQuery = q.Encode() + urlStr = parsed.String() + } + req, err := http.NewRequest( - http.MethodGet, s3.getURL(u.Bucket, u.ObjectKey), nil, + http.MethodGet, urlStr, nil, ) if err != nil { return nil, err @@ -315,8 +331,21 @@ func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) { // FileDelete makes a DELETE call with the file written as multipart // and on successful upload, checks for 204 No Content. func (s3 *S3) FileDelete(u DeleteInput) error { + urlStr := s3.getURL(u.Bucket, u.ObjectKey) + + if u.VersionId != "" { + parsed, err := url.Parse(urlStr) + if err != nil { + return err + } + q := parsed.Query() + q.Set("versionId", u.VersionId) + parsed.RawQuery = q.Encode() + urlStr = parsed.String() + } + req, err := http.NewRequest( - http.MethodDelete, s3.getURL(u.Bucket, u.ObjectKey), nil, + http.MethodDelete, urlStr, nil, ) if err != nil { return err @@ -351,8 +380,21 @@ func (s3 *S3) FileDelete(u DeleteInput) error { } func (s3 *S3) FileDetails(u DetailsInput) (DetailsResponse, error) { + urlStr := s3.getURL(u.Bucket, u.ObjectKey) + + if u.VersionId != "" { + parsed, err := url.Parse(urlStr) + if err != nil { + return DetailsResponse{}, err + } + q := parsed.Query() + q.Set("versionId", u.VersionId) + parsed.RawQuery = q.Encode() + urlStr = parsed.String() + } + req, err := http.NewRequest( - http.MethodHead, s3.getURL(u.Bucket, u.ObjectKey), nil, + http.MethodHead, urlStr, nil, ) if err != nil { return DetailsResponse{}, err diff --git a/versioning_test.go b/versioning_test.go new file mode 100644 index 0000000..8801009 --- /dev/null +++ b/versioning_test.go @@ -0,0 +1,173 @@ +package simples3 + +import ( + "fmt" + "io" + "strings" + "testing" + "time" +) + +func TestVersioning(t *testing.T) { + s3 := setupTestS3(t) + bucket := fmt.Sprintf("simples3-versioning-test-%d", time.Now().Unix()) + + // Create bucket + _, err := s3.CreateBucket(CreateBucketInput{Bucket: bucket}) + if err != nil { + t.Fatalf("CreateBucket failed: %v", err) + } + + // Cleanup function + defer func() { + // List all versions and delete markers to clean up + listResp, err := s3.ListVersions(ListVersionsInput{Bucket: bucket}) + if err == nil { + for _, v := range listResp.Versions { + s3.FileDelete(DeleteInput{Bucket: bucket, ObjectKey: v.Key, VersionId: v.VersionId}) + } + for _, d := range listResp.DeleteMarkers { + s3.FileDelete(DeleteInput{Bucket: bucket, ObjectKey: d.Key, VersionId: d.VersionId}) + } + } + s3.DeleteBucket(DeleteBucketInput{Bucket: bucket}) + }() + + // 1. Enable Versioning + err = s3.PutBucketVersioning(PutBucketVersioningInput{ + Bucket: bucket, + Status: "Enabled", + }) + if err != nil { + t.Fatalf("PutBucketVersioning failed: %v", err) + } + + // 2. Get Versioning Status + vConf, err := s3.GetBucketVersioning(bucket) + if err != nil { + t.Fatalf("GetBucketVersioning failed: %v", err) + } + if vConf.Status != "Enabled" { + t.Errorf("Expected versioning Enabled, got %s", vConf.Status) + } + + key := "test-object.txt" + + // 3. Upload v1 + _, err = s3.FilePut(UploadInput{ + Bucket: bucket, + ObjectKey: key, + 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, + ObjectKey: key, + Body: strings.NewReader("v2"), + }) + if err != nil { + t.Fatalf("FilePut v2 failed: %v", err) + } + + // 5. List Versions + listResp, err := s3.ListVersions(ListVersionsInput{ + Bucket: bucket, + Prefix: key, + }) + if err != nil { + t.Fatalf("ListVersions failed: %v", err) + } + + if len(listResp.Versions) != 2 { + t.Errorf("Expected 2 versions, got %d", len(listResp.Versions)) + } + + // Identify versions + var v1Id, v2Id string + // ListVersions returns latest first usually. + for _, v := range listResp.Versions { + if v.IsLatest { + v2Id = v.VersionId + } else { + v1Id = v.VersionId + } + } + + if v1Id == "" || v2Id == "" { + t.Fatalf("Failed to identify v1 and v2 IDs. Versions: %+v", listResp.Versions) + } + + // 6. Download v1 specific version + rc, err := s3.FileDownload(DownloadInput{ + Bucket: bucket, + ObjectKey: key, + VersionId: v1Id, + }) + if err != nil { + t.Fatalf("FileDownload v1 failed: %v", err) + } + content, _ := io.ReadAll(rc) + rc.Close() + if string(content) != "v1" { + t.Errorf("Expected v1 content 'v1', got '%s'", string(content)) + } + + // 7. Download v2 specific version + rc, err = s3.FileDownload(DownloadInput{ + Bucket: bucket, + ObjectKey: key, + VersionId: v2Id, + }) + if err != nil { + t.Fatalf("FileDownload v2 failed: %v", err) + } + content, _ = io.ReadAll(rc) + rc.Close() + 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, + ObjectKey: key, + VersionId: v1Id, + }) + if err != nil { + t.Fatalf("FileDetails v1 failed: %v", err) + } + if details.ContentLength != "2" { // "v1" is 2 bytes + t.Errorf("Expected v1 size 2, got %s", details.ContentLength) + } + + // 9. Delete v2 (latest) + err = s3.FileDelete(DeleteInput{ + Bucket: bucket, + ObjectKey: key, + VersionId: v2Id, + }) + if err != nil { + t.Fatalf("FileDelete v2 failed: %v", err) + } + + // 10. Verify current object is v1 + rc, err = s3.FileDownload(DownloadInput{ + Bucket: bucket, + ObjectKey: key, + }) + if err != nil { + t.Fatalf("FileDownload current failed: %v", err) + } + content, _ = io.ReadAll(rc) + rc.Close() + if string(content) != "v1" { + t.Errorf("Expected current content 'v1', got '%s'", string(content)) + } +}