Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions b2/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ func (options *composeOptions) validate() (contentType string, info map[string]s
return contentType, info, nil
}

func validateComposePlatformSize(size, intLimit uint64) error {
if size > intLimit {
return fmt.Errorf("b2: Compose uploaded part exceeds this platform's maximum buffer size")
}
return nil
}

func (p ComposePart) validate(nonFinal bool) error {
const minPartSize int64 = 5e6
const maxPartSize int64 = 5e9
Expand All @@ -125,8 +132,8 @@ func (p ComposePart) validate(nonFinal bool) error {
if p.size > maxPartSize {
return fmt.Errorf("b2: Compose uploaded part exceeds the 5 GB B2 part limit")
}
if uint64(p.size) > uint64(^uint(0)>>1) {
return fmt.Errorf("b2: Compose uploaded part exceeds this platform's maximum buffer size")
if err := validateComposePlatformSize(uint64(p.size), uint64(^uint(0)>>1)); err != nil {
return err
}
if nonFinal && p.size < minPartSize {
return fmt.Errorf("b2: Compose nonfinal part must be at least %d bytes", minPartSize)
Expand Down
57 changes: 47 additions & 10 deletions b2/compose_extended_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import (
"sync/atomic"
"testing"
"time"

"github.com/Backblaze/blazer/internal/b2wiretest"
)

const composeMinPartSize = 5e6
Expand Down Expand Up @@ -60,6 +62,7 @@ type composeWire struct {
starts []map[string]any
calls []string
copies []map[string]any
copyTimes []time.Time
uploads []composeUpload
finishes []map[string]any
}
Expand Down Expand Up @@ -149,6 +152,7 @@ func (w *composeWire) serveHTTP(rw http.ResponseWriter, r *http.Request) {
}
w.mu.Lock()
w.copies = append(w.copies, body)
w.copyTimes = append(w.copyTimes, time.Now())
w.calls = append(w.calls, fmt.Sprintf("copy:%d", partNumber))
fail := w.failCopyPart == partNumber
status := w.copyFailStatus
Expand All @@ -160,7 +164,7 @@ func (w *composeWire) serveHTTP(rw http.ResponseWriter, r *http.Request) {
w.mu.Unlock()
if status != 0 {
if status == http.StatusTooManyRequests {
rw.Header().Set("Retry-After", "1")
rw.Header().Set("Retry-After", "2")
}
rw.WriteHeader(status)
code := "service_unavailable"
Expand Down Expand Up @@ -290,6 +294,34 @@ func TestComposeRejectsMoreThan10000PartsBeforeStarting(t *testing.T) {
}
}

func TestComposeAccepts10000PartsValidation(t *testing.T) {
// https://www.backblaze.com/docs/cloud-storage-create-large-files-with-the-native-api
wire := b2wiretest.New(t)
wire.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`)
wire.Reply("b2_create_bucket", http.StatusOK, noReplicationBucketJSON)
// Stop after validation, avoiding 10000 network copies in this boundary test.
wire.Reply("b2_start_large_file", http.StatusBadRequest, `{"status":400,"code":"bad_request","message":"validated 10000 parts"}`)
client, err := NewClient(context.Background(), "account", "key", APIBase(wire.URL()))
if err != nil {
t.Fatal(err)
}
bucket, err := client.NewBucket(context.Background(), "n", nil)
if err != nil {
t.Fatal(err)
}
parts := make([]ComposePart, 10000)
for i := range parts {
parts[i] = PartFromCopy("source", "bytes=0-4999999")
}
_, err = bucket.Compose(context.Background(), "at-limit", parts)
if err == nil || err.Error() != "b2_start_large_file: 400: validated 10000 parts" {
t.Fatalf("Compose error = %v, want scripted error after successful validation", err)
}
if got := len(wire.Calls("b2_start_large_file")); got != 1 {
t.Fatalf("start requests = %d, want 1", got)
}
}

func TestComposeAllSourceMixesPreservePartHashOrder(t *testing.T) {
first := bytes.Repeat([]byte("x"), composeMinPartSize)
firstHash := fmt.Sprintf("%x", sha1.Sum(first))
Expand Down Expand Up @@ -735,30 +767,35 @@ func TestComposeCopyPartReauthAndBackoff(t *testing.T) {
for _, tc := range []struct {
name string
status, wantAuth int
minRetryWait time.Duration
}{
{"expired token", http.StatusUnauthorized, 2},
{"request timeout", http.StatusRequestTimeout, 1},
{"service unavailable", http.StatusServiceUnavailable, 1},
{"gateway timeout", http.StatusGatewayTimeout, 1},
{"rate limited", http.StatusTooManyRequests, 1},
{"expired token", http.StatusUnauthorized, 2, 0},
{"request timeout", http.StatusRequestTimeout, 1, time.Second},
{"service unavailable", http.StatusServiceUnavailable, 1, time.Second},
{"gateway timeout", http.StatusGatewayTimeout, 1, time.Second},
{"rate limited", http.StatusTooManyRequests, 1, 2 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
wire := newComposeWire(t)
wire.copyFailStatus = tc.status
bucket := wire.bucket(t)
started := time.Now()
_, err := bucket.Compose(context.Background(), "retry-copy", []ComposePart{
PartFromCopy("source-one", "bytes=0-4999999"),
PartFromCopy("source-two", "bytes=0-2"),
})
if err != nil {
t.Fatal(err)
}
if tc.status == http.StatusTooManyRequests && time.Since(started) < 900*time.Millisecond {
t.Fatal("copy_part retry ignored Retry-After: 1")
}
wire.mu.Lock()
defer wire.mu.Unlock()
if tc.minRetryWait > 0 {
if len(wire.copyTimes) < 2 {
t.Fatalf("copy_part status %d attempts = %d, want at least 2", tc.status, len(wire.copyTimes))
}
if waited := wire.copyTimes[1].Sub(wire.copyTimes[0]); waited < tc.minRetryWait {
t.Fatalf("copy_part status %d retry waited %v, want at least %v", tc.status, waited, tc.minRetryWait)
}
}
if len(wire.copies) != 3 || wire.authCalls != tc.wantAuth || !reflect.DeepEqual(wire.calls, []string{"start", "copy:1", "copy:1", "copy:2", "finish"}) {
t.Fatalf("copies=%d auth=%d calls=%v", len(wire.copies), wire.authCalls, wire.calls)
}
Expand Down
81 changes: 80 additions & 1 deletion b2/compose_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package b2

import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
Expand All @@ -12,6 +14,20 @@ func TestComposeUploadedPartPlatformSize(t *testing.T) {
if (err != nil) != (strconv.IntSize == 32) {
t.Fatalf("2 GiB part on %d-bit platform: %v", strconv.IntSize, err)
}
for _, size := range []uint64{2_147_483_646, 2_147_483_647, 2_147_483_648} {
err := validateComposePlatformSize(size, math.MaxInt32)
if size <= 2_147_483_647 && err != nil {
t.Fatalf("32-bit validation of %d = %v, want nil", size, err)
}
if size == 2_147_483_648 && (err == nil || err.Error() != "b2: Compose uploaded part exceeds this platform's maximum buffer size") {
t.Fatalf("32-bit validation of %d = %v, want platform-size error", size, err)
}
}
// The production call site still supplies the native int bound.
err = validateComposePlatformSize(2_147_483_648, uint64(^uint(0)>>1))
if (err != nil) != (strconv.IntSize == 32) {
t.Fatalf("native limit on %d-bit platform = %v", strconv.IntSize, err)
}
}

func FuzzComposeRange(f *testing.F) {
Expand All @@ -23,11 +39,74 @@ func FuzzComposeRange(f *testing.F) {
if p.validate(nonFinal) == nil && value != "" && !regexp.MustCompile(`^bytes=[0-9]+-[0-9]+$`).MatchString(value) {
t.Fatalf("accepted noncanonical range %q", value)
}
// Canonical ranges of at least 5 MB must always validate.
// The documented 5 MB nonfinal minimum and 5 GB maximum are inclusive.
// https://www.backblaze.com/docs/cloud-storage-create-large-files-with-the-native-api
n := int64(len(value))
for _, tc := range []struct {
value string
nonFinal bool
wantErr bool
}{
{fmt.Sprintf("bytes=%d-%d", n, n+4_999_998), true, true},
{fmt.Sprintf("bytes=%d-%d", n+1, n), nonFinal, true},
{fmt.Sprintf("bytes=%d-%d", n, n+5_000_000_000), nonFinal, true},
{"bytes=9000000-9000010", true, true},
{"bytes=9000000-13999999", true, false},
{"bytes=9000000-14999999", true, false},
} {
if err := PartFromCopy("source", tc.value).validate(tc.nonFinal); (err != nil) != tc.wantErr {
t.Fatalf("validate(%q, nonFinal=%t) error = %v, want error=%t", tc.value, tc.nonFinal, err, tc.wantErr)
}
}
// Canonical ranges of at least 5 MB must always validate.
canonical := PartFromCopy("source", "bytes="+strconv.FormatInt(n, 10)+"-"+strconv.FormatInt(n+4_999_999, 10))
if err := canonical.validate(nonFinal); err != nil {
t.Fatal(err)
}
})
}

func TestComposeRejectsZeroUploadedPart(t *testing.T) {
err := PartFromReader(strings.NewReader(""), 0).validate(false)
if err == nil || err.Error() != "b2: Compose uploaded part needs a positive size and no copy source" {
t.Fatalf("validate zero-size part = %v, want positive-size error", err)
}
}

func TestComposeSingleByteRange(t *testing.T) {
// https://www.backblaze.com/apidocs/b2-copy-part (inclusive Range endpoints).
start, end, err := parseComposeRange("bytes=5-5")
if start != 5 || end != 5 || err != nil {
t.Fatalf("parseComposeRange = (%d, %d, %v), want (5, 5, nil)", start, end, err)
}
}

func TestComposeRangeOrdering(t *testing.T) {
start, end, err := parseComposeRange("bytes=6-5")
if start != 0 || end != 0 || err == nil || err.Error() != `b2: Compose invalid copy range "bytes=6-5"` {
t.Fatalf("parseComposeRange = (%d, %d, %v), want reversed-range rejection", start, end, err)
}
}

func TestComposeCopiedKnownTotalBoundary(t *testing.T) {
// Isolate aggregation from the separate per-part limit: B2's file cap is 10 TB.
// https://www.backblaze.com/docs/cloud-storage-create-large-files-with-the-native-api
for _, tc := range []struct {
rangeEnd string
wantSize int64
wantErr bool
}{
{"5000000000098", 4_999_999_999_999, false},
{"5000000000100", 5_000_000_000_001, true},
} {
part := PartFromCopy("source", "bytes=100-"+tc.rangeEnd)
if size, known := part.knownSize(); !known || size != tc.wantSize {
t.Fatalf("knownSize = %d, %t; want %d, true", size, known, tc.wantSize)
}
err := validateComposeTotalSize([]ComposePart{part, part})
const want = "b2: Compose part 2 brings the known total over the 10 TB (10,000,000,000,000 bytes) large-file limit"
if tc.wantErr && (err == nil || err.Error() != want) || !tc.wantErr && err != nil {
t.Fatalf("total error = %v, want over-limit=%t", err, tc.wantErr)
}
}
}
35 changes: 23 additions & 12 deletions b2/create_cors_filelock_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
package b2

import (
"context"
"net/http"
"reflect"
"testing"

"github.com/Backblaze/blazer/base"
"github.com/Backblaze/blazer/internal/b2types"
"github.com/Backblaze/blazer/internal/b2wiretest"
)

// Literal b2_create_bucket response, independent of the SDK's JSON structs.
// https://www.backblaze.com/apidocs/b2-create-bucket
const attrsWireBucketJSON = `{"bucketId":"bucket-id","bucketName":"bucket","bucketType":"allPrivate","bucketInfo":{},"lifecycleRules":[],"revision":1,"corsRules":[{"corsRuleName":"browser","allowedOrigins":["https://example.com"],"allowedHeaders":["authorization"],"allowedOperations":["b2_download_file_by_name"],"exposeHeaders":["x-bz-file-name"],"maxAgeSeconds":60}],"fileLockConfiguration":{"isClientAuthorizedToRead":true,"value":{"isFileLockEnabled":true,"defaultRetention":{"mode":"governance","period":{"duration":7,"unit":"days"}}}}}`

func attrsWireBucket(t *testing.T) *b2Bucket {
t.Helper()
wire := b2wiretest.New(t)
wire.Reply("b2_create_bucket", http.StatusOK, attrsWireBucketJSON)
root, err := base.AuthorizeAccount(context.Background(), "account", "key", base.SetAPIBase(wire.URL()))
if err != nil {
t.Fatal(err)
}
bucket, err := root.CreateBucket(context.Background(), "bucket", "allPrivate", nil, nil, nil, nil, false, nil)
if err != nil {
t.Fatal(err)
}
return &b2Bucket{b: bucket}
}

func TestBucketAttrsCORSAndFileLock(t *testing.T) {
wantCORS := []CORSRule{{
Name: "browser",
Expand All @@ -17,17 +38,7 @@ func TestBucketAttrsCORSAndFileLock(t *testing.T) {
ExposeHeaders: []string{"x-bz-file-name"},
MaxAgeSeconds: 60,
}}
bucket := &b2Bucket{b: &base.Bucket{
CORSRules: []b2types.CORSRule{{
Name: wantCORS[0].Name,
AllowedOrigins: wantCORS[0].AllowedOrigins,
AllowedHeaders: wantCORS[0].AllowedHeaders,
AllowedOperations: wantCORS[0].AllowedOperations,
ExposeHeaders: wantCORS[0].ExposeHeaders,
MaxAgeSeconds: wantCORS[0].MaxAgeSeconds,
}},
FileLockEnabled: true,
}}
bucket := attrsWireBucket(t)

attrs := bucket.attrs()
if !reflect.DeepEqual(attrs.CORSRules, wantCORS) {
Expand Down
14 changes: 2 additions & 12 deletions b2/default_retention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,11 @@ package b2

import (
"testing"

"github.com/Backblaze/blazer/base"
"github.com/Backblaze/blazer/internal/b2types"
)

func TestBucketAttrsDefaultRetention(t *testing.T) {
bucket := &b2Bucket{b: &base.Bucket{
DefaultRetention: &b2types.Retention{
Mode: "governance",
Period: &b2types.RetentionPeriod{
Duration: 7,
Unit: "days",
},
},
}}
// https://www.backblaze.com/apidocs/b2-create-bucket (fileLockConfiguration).
bucket := attrsWireBucket(t)

attrs := bucket.attrs()
if attrs.DefaultRetention == nil {
Expand Down
6 changes: 6 additions & 0 deletions b2/delete_bypass_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func TestObjectDeleteBypassGovernanceReachesTheWire(t *testing.T) {
if v, ok := sent["bypassGovernance"]; ok {
t.Errorf("plain Delete sent bypassGovernance = %v, want the field omitted", v)
}
if sent["fileId"] != "id" || sent["fileName"] != "name" {
t.Errorf("plain Delete body = %v, want fileId=id and fileName=name", sent)
}

sent = nil
if err := newObject().Delete(ctx, BypassGovernance()); err != nil {
Expand All @@ -66,4 +69,7 @@ func TestObjectDeleteBypassGovernanceReachesTheWire(t *testing.T) {
if sent["bypassGovernance"] != true {
t.Errorf("Delete(BypassGovernance) sent bypassGovernance = %v, want true", sent["bypassGovernance"])
}
if sent["fileId"] != "id" || sent["fileName"] != "name" {
t.Errorf("bypass Delete body = %v, want fileId=id and fileName=name", sent)
}
}
Loading
Loading