@backblaze-labs/b2-sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -5
- package/README.md +163 -34
- package/dist/_virtual/_b2-sdk-version-json.cjs +4 -0
- package/dist/_virtual/_b2-sdk-version-json.js +4 -0
- package/dist/auth/account-info.d.cts +32 -0
- package/dist/auth/account-info.d.cts.map +1 -1
- package/dist/auth/account-info.d.ts +32 -0
- package/dist/auth/account-info.d.ts.map +1 -1
- package/dist/auth/file.cjs +408 -207
- package/dist/auth/file.cjs.map +1 -1
- package/dist/auth/file.d.cts +77 -3
- package/dist/auth/file.d.cts.map +1 -1
- package/dist/auth/file.d.ts +77 -3
- package/dist/auth/file.d.ts.map +1 -1
- package/dist/auth/file.js +407 -207
- package/dist/auth/file.js.map +1 -1
- package/dist/auth/in-memory.cjs +212 -183
- package/dist/auth/in-memory.cjs.map +1 -1
- package/dist/auth/in-memory.d.cts +8 -0
- package/dist/auth/in-memory.d.cts.map +1 -1
- package/dist/auth/in-memory.d.ts +8 -0
- package/dist/auth/in-memory.d.ts.map +1 -1
- package/dist/auth/in-memory.js +211 -182
- package/dist/auth/in-memory.js.map +1 -1
- package/dist/auth/index.cjs +7 -9
- package/dist/auth/index.d.cts +3 -2
- package/dist/auth/index.d.cts.map +1 -1
- package/dist/auth/index.d.ts +3 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -8
- package/dist/auth/realms.cjs +80 -7
- package/dist/auth/realms.cjs.map +1 -1
- package/dist/auth/realms.d.cts +27 -4
- package/dist/auth/realms.d.cts.map +1 -1
- package/dist/auth/realms.d.ts +27 -4
- package/dist/auth/realms.d.ts.map +1 -1
- package/dist/auth/realms.js +80 -9
- package/dist/auth/realms.js.map +1 -1
- package/dist/auth/upload-url-pool.cjs +55 -51
- package/dist/auth/upload-url-pool.cjs.map +1 -1
- package/dist/auth/upload-url-pool.js +55 -51
- package/dist/auth/upload-url-pool.js.map +1 -1
- package/dist/backup/client.cjs +206 -0
- package/dist/backup/client.cjs.map +1 -0
- package/dist/backup/client.d.cts +238 -0
- package/dist/backup/client.d.cts.map +1 -0
- package/dist/backup/client.d.ts +238 -0
- package/dist/backup/client.d.ts.map +1 -0
- package/dist/backup/client.js +206 -0
- package/dist/backup/client.js.map +1 -0
- package/dist/backup/index.cjs +15 -0
- package/dist/backup/index.d.cts +22 -0
- package/dist/backup/index.d.cts.map +1 -0
- package/dist/backup/index.d.ts +22 -0
- package/dist/backup/index.d.ts.map +1 -0
- package/dist/backup/index.js +7 -0
- package/dist/backup/raw.cjs +146 -0
- package/dist/backup/raw.cjs.map +1 -0
- package/dist/backup/raw.d.cts +122 -0
- package/dist/backup/raw.d.cts.map +1 -0
- package/dist/backup/raw.d.ts +122 -0
- package/dist/backup/raw.d.ts.map +1 -0
- package/dist/backup/raw.js +146 -0
- package/dist/backup/raw.js.map +1 -0
- package/dist/bucket.cjs +786 -787
- package/dist/bucket.cjs.map +1 -1
- package/dist/bucket.d.cts +35 -47
- package/dist/bucket.d.cts.map +1 -1
- package/dist/bucket.d.ts +35 -47
- package/dist/bucket.d.ts.map +1 -1
- package/dist/bucket.js +781 -782
- package/dist/bucket.js.map +1 -1
- package/dist/client.cjs +246 -228
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +25 -17
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.ts +25 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +243 -225
- package/dist/client.js.map +1 -1
- package/dist/copy/large.cjs +116 -71
- package/dist/copy/large.cjs.map +1 -1
- package/dist/copy/large.d.cts +6 -0
- package/dist/copy/large.d.cts.map +1 -1
- package/dist/copy/large.d.ts +6 -0
- package/dist/copy/large.d.ts.map +1 -1
- package/dist/copy/large.js +114 -69
- package/dist/copy/large.js.map +1 -1
- package/dist/download/checksum.cjs +93 -0
- package/dist/download/checksum.cjs.map +1 -0
- package/dist/download/checksum.d.cts +40 -0
- package/dist/download/checksum.d.cts.map +1 -0
- package/dist/download/checksum.d.ts +40 -0
- package/dist/download/checksum.d.ts.map +1 -0
- package/dist/download/checksum.js +91 -0
- package/dist/download/checksum.js.map +1 -0
- package/dist/download/parallel.cjs +251 -118
- package/dist/download/parallel.cjs.map +1 -1
- package/dist/download/parallel.d.cts +13 -2
- package/dist/download/parallel.d.cts.map +1 -1
- package/dist/download/parallel.d.ts +13 -2
- package/dist/download/parallel.d.ts.map +1 -1
- package/dist/download/parallel.js +249 -116
- package/dist/download/parallel.js.map +1 -1
- package/dist/download/single.cjs +323 -97
- package/dist/download/single.cjs.map +1 -1
- package/dist/download/single.d.cts +6 -1
- package/dist/download/single.d.cts.map +1 -1
- package/dist/download/single.d.ts +6 -1
- package/dist/download/single.d.ts.map +1 -1
- package/dist/download/single.js +322 -99
- package/dist/download/single.js.map +1 -1
- package/dist/errors/index.cjs +869 -243
- package/dist/errors/index.cjs.map +1 -1
- package/dist/errors/index.d.cts +404 -82
- package/dist/errors/index.d.cts.map +1 -1
- package/dist/errors/index.d.ts +404 -82
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +835 -261
- package/dist/errors/index.js.map +1 -1
- package/dist/http/retry.cjs +45 -28
- package/dist/http/retry.cjs.map +1 -1
- package/dist/http/retry.d.cts +15 -2
- package/dist/http/retry.d.cts.map +1 -1
- package/dist/http/retry.d.ts +15 -2
- package/dist/http/retry.d.ts.map +1 -1
- package/dist/http/retry.js +46 -31
- package/dist/http/retry.js.map +1 -1
- package/dist/http/transport.cjs +476 -140
- package/dist/http/transport.cjs.map +1 -1
- package/dist/http/transport.d.cts +46 -5
- package/dist/http/transport.d.cts.map +1 -1
- package/dist/http/transport.d.ts +46 -5
- package/dist/http/transport.d.ts.map +1 -1
- package/dist/http/transport.js +474 -140
- package/dist/http/transport.js.map +1 -1
- package/dist/http/url-guard.cjs +154 -89
- package/dist/http/url-guard.cjs.map +1 -1
- package/dist/http/url-guard.d.cts +25 -3
- package/dist/http/url-guard.d.cts.map +1 -1
- package/dist/http/url-guard.d.ts +25 -3
- package/dist/http/url-guard.d.ts.map +1 -1
- package/dist/http/url-guard.js +152 -90
- package/dist/http/url-guard.js.map +1 -1
- package/dist/http/user-agent.cjs +84 -44
- package/dist/http/user-agent.cjs.map +1 -1
- package/dist/http/user-agent.js +84 -46
- package/dist/http/user-agent.js.map +1 -1
- package/dist/index.cjs +124 -77
- package/dist/index.d.cts +29 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +29 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -72
- package/dist/internal/b2-naming.cjs +66 -0
- package/dist/internal/b2-naming.cjs.map +1 -0
- package/dist/internal/b2-naming.d.cts +43 -0
- package/dist/internal/b2-naming.d.cts.map +1 -0
- package/dist/internal/b2-naming.d.ts +43 -0
- package/dist/internal/b2-naming.d.ts.map +1 -0
- package/dist/internal/b2-naming.js +60 -0
- package/dist/internal/b2-naming.js.map +1 -0
- package/dist/internal/upload-retry-options.cjs +21 -0
- package/dist/internal/upload-retry-options.cjs.map +1 -0
- package/dist/internal/upload-retry-options.d.cts +12 -0
- package/dist/internal/upload-retry-options.d.cts.map +1 -0
- package/dist/internal/upload-retry-options.d.ts +12 -0
- package/dist/internal/upload-retry-options.d.ts.map +1 -0
- package/dist/internal/upload-retry-options.js +21 -0
- package/dist/internal/upload-retry-options.js.map +1 -0
- package/dist/internal/url-redaction.cjs +31 -0
- package/dist/internal/url-redaction.cjs.map +1 -0
- package/dist/internal/url-redaction.d.cts +17 -0
- package/dist/internal/url-redaction.d.cts.map +1 -0
- package/dist/internal/url-redaction.d.ts +17 -0
- package/dist/internal/url-redaction.d.ts.map +1 -0
- package/dist/internal/url-redaction.js +31 -0
- package/dist/internal/url-redaction.js.map +1 -0
- package/dist/notifications/index.cjs +4 -6
- package/dist/notifications/index.d.cts +1 -1
- package/dist/notifications/index.d.cts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +1 -6
- package/dist/notifications/webhook.cjs +195 -85
- package/dist/notifications/webhook.cjs.map +1 -1
- package/dist/notifications/webhook.d.cts.map +1 -1
- package/dist/notifications/webhook.d.ts.map +1 -1
- package/dist/notifications/webhook.js +195 -87
- package/dist/notifications/webhook.js.map +1 -1
- package/dist/object.cjs +253 -210
- package/dist/object.cjs.map +1 -1
- package/dist/object.d.cts +13 -57
- package/dist/object.d.cts.map +1 -1
- package/dist/object.d.ts +13 -57
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +249 -206
- package/dist/object.js.map +1 -1
- package/dist/partner/account-info.d.cts +43 -0
- package/dist/partner/account-info.d.cts.map +1 -0
- package/dist/partner/account-info.d.ts +43 -0
- package/dist/partner/account-info.d.ts.map +1 -0
- package/dist/partner/auth-clone.cjs +64 -0
- package/dist/partner/auth-clone.cjs.map +1 -0
- package/dist/partner/auth-clone.d.cts +27 -0
- package/dist/partner/auth-clone.d.cts.map +1 -0
- package/dist/partner/auth-clone.d.ts +27 -0
- package/dist/partner/auth-clone.d.ts.map +1 -0
- package/dist/partner/auth-clone.js +63 -0
- package/dist/partner/auth-clone.js.map +1 -0
- package/dist/partner/auth-core.cjs +175 -0
- package/dist/partner/auth-core.cjs.map +1 -0
- package/dist/partner/auth-core.d.cts +111 -0
- package/dist/partner/auth-core.d.cts.map +1 -0
- package/dist/partner/auth-core.d.ts +111 -0
- package/dist/partner/auth-core.d.ts.map +1 -0
- package/dist/partner/auth-core.js +175 -0
- package/dist/partner/auth-core.js.map +1 -0
- package/dist/partner/auth-shape.cjs +36 -0
- package/dist/partner/auth-shape.cjs.map +1 -0
- package/dist/partner/auth-shape.d.cts +12 -0
- package/dist/partner/auth-shape.d.cts.map +1 -0
- package/dist/partner/auth-shape.d.ts +12 -0
- package/dist/partner/auth-shape.d.ts.map +1 -0
- package/dist/partner/auth-shape.js +36 -0
- package/dist/partner/auth-shape.js.map +1 -0
- package/dist/partner/client.cjs +255 -0
- package/dist/partner/client.cjs.map +1 -0
- package/dist/partner/client.d.cts +335 -0
- package/dist/partner/client.d.cts.map +1 -0
- package/dist/partner/client.d.ts +335 -0
- package/dist/partner/client.d.ts.map +1 -0
- package/dist/partner/client.js +255 -0
- package/dist/partner/client.js.map +1 -0
- package/dist/partner/endpoint-url.cjs +108 -0
- package/dist/partner/endpoint-url.cjs.map +1 -0
- package/dist/partner/endpoint-url.d.cts +72 -0
- package/dist/partner/endpoint-url.d.cts.map +1 -0
- package/dist/partner/endpoint-url.d.ts +72 -0
- package/dist/partner/endpoint-url.d.ts.map +1 -0
- package/dist/partner/endpoint-url.js +105 -0
- package/dist/partner/endpoint-url.js.map +1 -0
- package/dist/partner/in-memory.cjs +135 -0
- package/dist/partner/in-memory.cjs.map +1 -0
- package/dist/partner/in-memory.d.cts +99 -0
- package/dist/partner/in-memory.d.cts.map +1 -0
- package/dist/partner/in-memory.d.ts +99 -0
- package/dist/partner/in-memory.d.ts.map +1 -0
- package/dist/partner/in-memory.js +135 -0
- package/dist/partner/in-memory.js.map +1 -0
- package/dist/partner/index.cjs +18 -0
- package/dist/partner/index.d.cts +23 -0
- package/dist/partner/index.d.cts.map +1 -0
- package/dist/partner/index.d.ts +23 -0
- package/dist/partner/index.d.ts.map +1 -0
- package/dist/partner/index.js +7 -0
- package/dist/partner/raw.cjs +392 -0
- package/dist/partner/raw.cjs.map +1 -0
- package/dist/partner/raw.d.cts +223 -0
- package/dist/partner/raw.d.cts.map +1 -0
- package/dist/partner/raw.d.ts +223 -0
- package/dist/partner/raw.d.ts.map +1 -0
- package/dist/partner/raw.js +390 -0
- package/dist/partner/raw.js.map +1 -0
- package/dist/partner/redaction-placeholders.cjs +10 -0
- package/dist/partner/redaction-placeholders.cjs.map +1 -0
- package/dist/partner/redaction-placeholders.d.cts +5 -0
- package/dist/partner/redaction-placeholders.d.cts.map +1 -0
- package/dist/partner/redaction-placeholders.d.ts +5 -0
- package/dist/partner/redaction-placeholders.d.ts.map +1 -0
- package/dist/partner/redaction-placeholders.js +9 -0
- package/dist/partner/redaction-placeholders.js.map +1 -0
- package/dist/partner/redaction.cjs +267 -0
- package/dist/partner/redaction.cjs.map +1 -0
- package/dist/partner/redaction.d.cts +124 -0
- package/dist/partner/redaction.d.cts.map +1 -0
- package/dist/partner/redaction.d.ts +124 -0
- package/dist/partner/redaction.d.ts.map +1 -0
- package/dist/partner/redaction.js +257 -0
- package/dist/partner/redaction.js.map +1 -0
- package/dist/raw/encoding.cjs +74 -35
- package/dist/raw/encoding.cjs.map +1 -1
- package/dist/raw/encoding.js +74 -38
- package/dist/raw/encoding.js.map +1 -1
- package/dist/raw/index.cjs +740 -587
- package/dist/raw/index.cjs.map +1 -1
- package/dist/raw/index.d.cts +126 -19
- package/dist/raw/index.d.cts.map +1 -1
- package/dist/raw/index.d.ts +126 -19
- package/dist/raw/index.d.ts.map +1 -1
- package/dist/raw/index.js +736 -589
- package/dist/raw/index.js.map +1 -1
- package/dist/raw/url.cjs +111 -0
- package/dist/raw/url.cjs.map +1 -0
- package/dist/raw/url.d.cts +41 -0
- package/dist/raw/url.d.cts.map +1 -0
- package/dist/raw/url.d.ts +41 -0
- package/dist/raw/url.d.ts.map +1 -0
- package/dist/raw/url.js +110 -0
- package/dist/raw/url.js.map +1 -0
- package/dist/s3/index.cjs +355 -18
- package/dist/s3/index.cjs.map +1 -1
- package/dist/s3/index.d.cts +233 -5
- package/dist/s3/index.d.cts.map +1 -1
- package/dist/s3/index.d.ts +233 -5
- package/dist/s3/index.d.ts.map +1 -1
- package/dist/s3/index.js +351 -22
- package/dist/s3/index.js.map +1 -1
- package/dist/s3/sigv4.cjs +154 -0
- package/dist/s3/sigv4.cjs.map +1 -0
- package/dist/s3/sigv4.d.cts +43 -0
- package/dist/s3/sigv4.d.cts.map +1 -0
- package/dist/s3/sigv4.d.ts +43 -0
- package/dist/s3/sigv4.d.ts.map +1 -0
- package/dist/s3/sigv4.js +153 -0
- package/dist/s3/sigv4.js.map +1 -0
- package/dist/s3/validation.cjs +69 -0
- package/dist/s3/validation.cjs.map +1 -0
- package/dist/s3/validation.d.cts +43 -0
- package/dist/s3/validation.d.cts.map +1 -0
- package/dist/s3/validation.d.ts +43 -0
- package/dist/s3/validation.d.ts.map +1 -0
- package/dist/s3/validation.js +66 -0
- package/dist/s3/validation.js.map +1 -0
- package/dist/simulator/capabilities.cjs +63 -48
- package/dist/simulator/capabilities.cjs.map +1 -1
- package/dist/simulator/capabilities.js +63 -49
- package/dist/simulator/capabilities.js.map +1 -1
- package/dist/simulator/index.cjs +3114 -1547
- package/dist/simulator/index.cjs.map +1 -1
- package/dist/simulator/index.d.cts +186 -20
- package/dist/simulator/index.d.cts.map +1 -1
- package/dist/simulator/index.d.ts +186 -20
- package/dist/simulator/index.d.ts.map +1 -1
- package/dist/simulator/index.js +3098 -1545
- package/dist/simulator/index.js.map +1 -1
- package/dist/simulator/partner.cjs +719 -0
- package/dist/simulator/partner.cjs.map +1 -0
- package/dist/simulator/partner.d.cts +183 -0
- package/dist/simulator/partner.d.cts.map +1 -0
- package/dist/simulator/partner.d.ts +183 -0
- package/dist/simulator/partner.d.ts.map +1 -0
- package/dist/simulator/partner.js +718 -0
- package/dist/simulator/partner.js.map +1 -0
- package/dist/simulator/validation.cjs +772 -157
- package/dist/simulator/validation.cjs.map +1 -1
- package/dist/simulator/validation.d.cts +120 -21
- package/dist/simulator/validation.d.cts.map +1 -1
- package/dist/simulator/validation.d.ts +120 -21
- package/dist/simulator/validation.d.ts.map +1 -1
- package/dist/simulator/validation.js +755 -167
- package/dist/simulator/validation.js.map +1 -1
- package/dist/streams/collect.cjs +74 -24
- package/dist/streams/collect.cjs.map +1 -1
- package/dist/streams/collect.d.cts +14 -1
- package/dist/streams/collect.d.cts.map +1 -1
- package/dist/streams/collect.d.ts +14 -1
- package/dist/streams/collect.d.ts.map +1 -1
- package/dist/streams/collect.js +74 -25
- package/dist/streams/collect.js.map +1 -1
- package/dist/streams/file-source.cjs +287 -0
- package/dist/streams/file-source.cjs.map +1 -0
- package/dist/streams/file-source.d.cts +96 -0
- package/dist/streams/file-source.d.cts.map +1 -0
- package/dist/streams/file-source.d.ts +96 -0
- package/dist/streams/file-source.d.ts.map +1 -0
- package/dist/streams/file-source.js +286 -0
- package/dist/streams/file-source.js.map +1 -0
- package/dist/streams/hash.cjs +207 -115
- package/dist/streams/hash.cjs.map +1 -1
- package/dist/streams/hash.d.cts +5 -4
- package/dist/streams/hash.d.cts.map +1 -1
- package/dist/streams/hash.d.ts +5 -4
- package/dist/streams/hash.d.ts.map +1 -1
- package/dist/streams/hash.js +208 -95
- package/dist/streams/hash.js.map +1 -1
- package/dist/streams/index.cjs +12 -12
- package/dist/streams/index.d.cts +3 -3
- package/dist/streams/index.d.cts.map +1 -1
- package/dist/streams/index.d.ts +3 -3
- package/dist/streams/index.d.ts.map +1 -1
- package/dist/streams/index.js +3 -11
- package/dist/streams/progress.cjs +60 -46
- package/dist/streams/progress.cjs.map +1 -1
- package/dist/streams/progress.js +60 -46
- package/dist/streams/progress.js.map +1 -1
- package/dist/streams/source.cjs +317 -146
- package/dist/streams/source.cjs.map +1 -1
- package/dist/streams/source.d.cts +56 -18
- package/dist/streams/source.d.cts.map +1 -1
- package/dist/streams/source.d.ts +56 -18
- package/dist/streams/source.d.ts.map +1 -1
- package/dist/streams/source.js +313 -149
- package/dist/streams/source.js.map +1 -1
- package/dist/sync/actions/index.cjs +236 -184
- package/dist/sync/actions/index.cjs.map +1 -1
- package/dist/sync/actions/index.d.cts +29 -21
- package/dist/sync/actions/index.d.cts.map +1 -1
- package/dist/sync/actions/index.d.ts +29 -21
- package/dist/sync/actions/index.d.ts.map +1 -1
- package/dist/sync/actions/index.js +236 -190
- package/dist/sync/actions/index.js.map +1 -1
- package/dist/sync/b2-sha1-reader.cjs +152 -0
- package/dist/sync/b2-sha1-reader.cjs.map +1 -0
- package/dist/sync/b2-sha1-reader.d.cts +58 -0
- package/dist/sync/b2-sha1-reader.d.cts.map +1 -0
- package/dist/sync/b2-sha1-reader.d.ts +58 -0
- package/dist/sync/b2-sha1-reader.d.ts.map +1 -0
- package/dist/sync/b2-sha1-reader.js +149 -0
- package/dist/sync/b2-sha1-reader.js.map +1 -0
- package/dist/sync/download-staging.cjs +266 -0
- package/dist/sync/download-staging.cjs.map +1 -0
- package/dist/sync/download-staging.d.cts +55 -0
- package/dist/sync/download-staging.d.cts.map +1 -0
- package/dist/sync/download-staging.d.ts +55 -0
- package/dist/sync/download-staging.d.ts.map +1 -0
- package/dist/sync/download-staging.js +260 -0
- package/dist/sync/download-staging.js.map +1 -0
- package/dist/sync/filesystem-errors.cjs +32 -0
- package/dist/sync/filesystem-errors.cjs.map +1 -0
- package/dist/sync/filesystem-errors.d.cts +8 -0
- package/dist/sync/filesystem-errors.d.cts.map +1 -0
- package/dist/sync/filesystem-errors.d.ts +8 -0
- package/dist/sync/filesystem-errors.d.ts.map +1 -0
- package/dist/sync/filesystem-errors.js +32 -0
- package/dist/sync/filesystem-errors.js.map +1 -0
- package/dist/sync/filters.cjs +217 -0
- package/dist/sync/filters.cjs.map +1 -0
- package/dist/sync/filters.d.cts +51 -0
- package/dist/sync/filters.d.cts.map +1 -0
- package/dist/sync/filters.d.ts +51 -0
- package/dist/sync/filters.d.ts.map +1 -0
- package/dist/sync/filters.js +213 -0
- package/dist/sync/filters.js.map +1 -0
- package/dist/sync/index.cjs +39 -22
- package/dist/sync/index.d.cts +14 -9
- package/dist/sync/index.d.cts.map +1 -1
- package/dist/sync/index.d.ts +14 -9
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +9 -20
- package/dist/sync/local-file-identity.cjs +61 -0
- package/dist/sync/local-file-identity.cjs.map +1 -0
- package/dist/sync/local-file-identity.d.cts +37 -0
- package/dist/sync/local-file-identity.d.cts.map +1 -0
- package/dist/sync/local-file-identity.d.ts +37 -0
- package/dist/sync/local-file-identity.d.ts.map +1 -0
- package/dist/sync/local-file-identity.js +60 -0
- package/dist/sync/local-file-identity.js.map +1 -0
- package/dist/sync/local-file-io.cjs +369 -0
- package/dist/sync/local-file-io.cjs.map +1 -0
- package/dist/sync/local-file-io.d.cts +76 -0
- package/dist/sync/local-file-io.d.cts.map +1 -0
- package/dist/sync/local-file-io.d.ts +76 -0
- package/dist/sync/local-file-io.d.ts.map +1 -0
- package/dist/sync/local-file-io.js +365 -0
- package/dist/sync/local-file-io.js.map +1 -0
- package/dist/sync/local-filesystem-root.cjs +27 -0
- package/dist/sync/local-filesystem-root.cjs.map +1 -0
- package/dist/sync/local-filesystem-root.d.cts +18 -0
- package/dist/sync/local-filesystem-root.d.cts.map +1 -0
- package/dist/sync/local-filesystem-root.d.ts +18 -0
- package/dist/sync/local-filesystem-root.d.ts.map +1 -0
- package/dist/sync/local-filesystem-root.js +26 -0
- package/dist/sync/local-filesystem-root.js.map +1 -0
- package/dist/sync/local-sha1.cjs +110 -0
- package/dist/sync/local-sha1.cjs.map +1 -0
- package/dist/sync/local-sha1.d.cts +31 -0
- package/dist/sync/local-sha1.d.cts.map +1 -0
- package/dist/sync/local-sha1.d.ts +31 -0
- package/dist/sync/local-sha1.d.ts.map +1 -0
- package/dist/sync/local-sha1.js +109 -0
- package/dist/sync/local-sha1.js.map +1 -0
- package/dist/sync/pairing.cjs +118 -29
- package/dist/sync/pairing.cjs.map +1 -1
- package/dist/sync/pairing.d.cts +7 -2
- package/dist/sync/pairing.d.cts.map +1 -1
- package/dist/sync/pairing.d.ts +7 -2
- package/dist/sync/pairing.d.ts.map +1 -1
- package/dist/sync/pairing.js +120 -31
- package/dist/sync/pairing.js.map +1 -1
- package/dist/sync/path-order.cjs +30 -0
- package/dist/sync/path-order.cjs.map +1 -0
- package/dist/sync/path-order.d.cts +28 -0
- package/dist/sync/path-order.d.cts.map +1 -0
- package/dist/sync/path-order.d.ts +28 -0
- package/dist/sync/path-order.d.ts.map +1 -0
- package/dist/sync/path-order.js +29 -0
- package/dist/sync/path-order.js.map +1 -0
- package/dist/sync/path-safety.cjs +111 -0
- package/dist/sync/path-safety.cjs.map +1 -0
- package/dist/sync/path-safety.d.cts +78 -0
- package/dist/sync/path-safety.d.cts.map +1 -0
- package/dist/sync/path-safety.d.ts +78 -0
- package/dist/sync/path-safety.d.ts.map +1 -0
- package/dist/sync/path-safety.js +105 -0
- package/dist/sync/path-safety.js.map +1 -0
- package/dist/sync/paths.d.cts +19 -0
- package/dist/sync/paths.d.cts.map +1 -0
- package/dist/sync/paths.d.ts +19 -0
- package/dist/sync/paths.d.ts.map +1 -0
- package/dist/sync/policies/compare.cjs +391 -11
- package/dist/sync/policies/compare.cjs.map +1 -1
- package/dist/sync/policies/compare.d.cts +100 -2
- package/dist/sync/policies/compare.d.cts.map +1 -1
- package/dist/sync/policies/compare.d.ts +100 -2
- package/dist/sync/policies/compare.d.ts.map +1 -1
- package/dist/sync/policies/compare.js +389 -13
- package/dist/sync/policies/compare.js.map +1 -1
- package/dist/sync/policies/index.cjs +70 -65
- package/dist/sync/policies/index.cjs.map +1 -1
- package/dist/sync/policies/index.d.cts +35 -6
- package/dist/sync/policies/index.d.cts.map +1 -1
- package/dist/sync/policies/index.d.ts +35 -6
- package/dist/sync/policies/index.d.ts.map +1 -1
- package/dist/sync/policies/index.js +70 -65
- package/dist/sync/policies/index.js.map +1 -1
- package/dist/sync/prefix.cjs +96 -0
- package/dist/sync/prefix.cjs.map +1 -0
- package/dist/sync/prefix.d.cts +55 -0
- package/dist/sync/prefix.d.cts.map +1 -0
- package/dist/sync/prefix.d.ts +55 -0
- package/dist/sync/prefix.d.ts.map +1 -0
- package/dist/sync/prefix.js +92 -0
- package/dist/sync/prefix.js.map +1 -0
- package/dist/sync/regexp-safety.cjs +192 -0
- package/dist/sync/regexp-safety.cjs.map +1 -0
- package/dist/sync/regexp-safety.d.cts +29 -0
- package/dist/sync/regexp-safety.d.cts.map +1 -0
- package/dist/sync/regexp-safety.d.ts +29 -0
- package/dist/sync/regexp-safety.d.ts.map +1 -0
- package/dist/sync/regexp-safety.js +190 -0
- package/dist/sync/regexp-safety.js.map +1 -0
- package/dist/sync/scan-events.cjs +33 -0
- package/dist/sync/scan-events.cjs.map +1 -0
- package/dist/sync/scan-events.d.cts +17 -0
- package/dist/sync/scan-events.d.cts.map +1 -0
- package/dist/sync/scan-events.d.ts +17 -0
- package/dist/sync/scan-events.d.ts.map +1 -0
- package/dist/sync/scan-events.js +32 -0
- package/dist/sync/scan-events.js.map +1 -0
- package/dist/sync/scan-limit.cjs +33 -0
- package/dist/sync/scan-limit.cjs.map +1 -0
- package/dist/sync/scan-limit.d.cts +21 -0
- package/dist/sync/scan-limit.d.cts.map +1 -0
- package/dist/sync/scan-limit.d.ts +21 -0
- package/dist/sync/scan-limit.d.ts.map +1 -0
- package/dist/sync/scan-limit.js +31 -0
- package/dist/sync/scan-limit.js.map +1 -0
- package/dist/sync/scanners/b2.cjs +239 -55
- package/dist/sync/scanners/b2.cjs.map +1 -1
- package/dist/sync/scanners/b2.d.cts +26 -7
- package/dist/sync/scanners/b2.d.cts.map +1 -1
- package/dist/sync/scanners/b2.d.ts +26 -7
- package/dist/sync/scanners/b2.d.ts.map +1 -1
- package/dist/sync/scanners/b2.js +239 -55
- package/dist/sync/scanners/b2.js.map +1 -1
- package/dist/sync/scanners/local.cjs +215 -56
- package/dist/sync/scanners/local.cjs.map +1 -1
- package/dist/sync/scanners/local.d.cts +22 -7
- package/dist/sync/scanners/local.d.cts.map +1 -1
- package/dist/sync/scanners/local.d.ts +22 -7
- package/dist/sync/scanners/local.d.ts.map +1 -1
- package/dist/sync/scanners/local.js +216 -57
- package/dist/sync/scanners/local.js.map +1 -1
- package/dist/sync/sha1-metadata.cjs +94 -0
- package/dist/sync/sha1-metadata.cjs.map +1 -0
- package/dist/sync/sha1-metadata.d.cts +82 -0
- package/dist/sync/sha1-metadata.d.cts.map +1 -0
- package/dist/sync/sha1-metadata.d.ts +82 -0
- package/dist/sync/sha1-metadata.d.ts.map +1 -0
- package/dist/sync/sha1-metadata.js +89 -0
- package/dist/sync/sha1-metadata.js.map +1 -0
- package/dist/sync/sha1-options.cjs +23 -0
- package/dist/sync/sha1-options.cjs.map +1 -0
- package/dist/sync/sha1-options.d.cts +14 -0
- package/dist/sync/sha1-options.d.cts.map +1 -0
- package/dist/sync/sha1-options.d.ts +14 -0
- package/dist/sync/sha1-options.d.ts.map +1 -0
- package/dist/sync/sha1-options.js +21 -0
- package/dist/sync/sha1-options.js.map +1 -0
- package/dist/sync/synchronizer.cjs +783 -197
- package/dist/sync/synchronizer.cjs.map +1 -1
- package/dist/sync/synchronizer.d.cts +47 -1
- package/dist/sync/synchronizer.d.cts.map +1 -1
- package/dist/sync/synchronizer.d.ts +47 -1
- package/dist/sync/synchronizer.d.ts.map +1 -1
- package/dist/sync/synchronizer.js +780 -173
- package/dist/sync/synchronizer.js.map +1 -1
- package/dist/sync/types.d.cts +208 -16
- package/dist/sync/types.d.cts.map +1 -1
- package/dist/sync/types.d.ts +208 -16
- package/dist/sync/types.d.ts.map +1 -1
- package/dist/types/auth.cjs +80 -56
- package/dist/types/auth.cjs.map +1 -1
- package/dist/types/auth.d.cts +71 -9
- package/dist/types/auth.d.cts.map +1 -1
- package/dist/types/auth.d.ts +71 -9
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/types/auth.js +81 -57
- package/dist/types/auth.js.map +1 -1
- package/dist/types/backup.d.cts +80 -0
- package/dist/types/backup.d.cts.map +1 -0
- package/dist/types/backup.d.ts +80 -0
- package/dist/types/backup.d.ts.map +1 -0
- package/dist/types/bucket.cjs +74 -38
- package/dist/types/bucket.cjs.map +1 -1
- package/dist/types/bucket.d.cts +4 -2
- package/dist/types/bucket.d.cts.map +1 -1
- package/dist/types/bucket.d.ts +4 -2
- package/dist/types/bucket.d.ts.map +1 -1
- package/dist/types/bucket.js +75 -41
- package/dist/types/bucket.js.map +1 -1
- package/dist/types/download.cjs +62 -0
- package/dist/types/download.cjs.map +1 -0
- package/dist/types/download.d.cts +98 -0
- package/dist/types/download.d.cts.map +1 -1
- package/dist/types/download.d.ts +98 -0
- package/dist/types/download.d.ts.map +1 -1
- package/dist/types/download.js +61 -0
- package/dist/types/download.js.map +1 -0
- package/dist/types/encryption.cjs +194 -328
- package/dist/types/encryption.cjs.map +1 -1
- package/dist/types/encryption.d.cts +48 -0
- package/dist/types/encryption.d.cts.map +1 -1
- package/dist/types/encryption.d.ts +48 -0
- package/dist/types/encryption.d.ts.map +1 -1
- package/dist/types/encryption.js +192 -311
- package/dist/types/encryption.js.map +1 -1
- package/dist/types/errors.cjs +56 -0
- package/dist/types/errors.cjs.map +1 -0
- package/dist/types/errors.d.cts +13 -4
- package/dist/types/errors.d.cts.map +1 -1
- package/dist/types/errors.d.ts +13 -4
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +56 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/file.cjs +36 -19
- package/dist/types/file.cjs.map +1 -1
- package/dist/types/file.d.cts +17 -4
- package/dist/types/file.d.cts.map +1 -1
- package/dist/types/file.d.ts +17 -4
- package/dist/types/file.d.ts.map +1 -1
- package/dist/types/file.js +37 -21
- package/dist/types/file.js.map +1 -1
- package/dist/types/ids.cjs +83 -9
- package/dist/types/ids.cjs.map +1 -1
- package/dist/types/ids.d.cts +32 -0
- package/dist/types/ids.d.cts.map +1 -1
- package/dist/types/ids.d.ts +32 -0
- package/dist/types/ids.d.ts.map +1 -1
- package/dist/types/ids.js +82 -16
- package/dist/types/ids.js.map +1 -1
- package/dist/types/index.d.cts +18 -13
- package/dist/types/index.d.cts.map +1 -1
- package/dist/types/index.d.ts +18 -13
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/key.d.cts +44 -8
- package/dist/types/key.d.cts.map +1 -1
- package/dist/types/key.d.ts +44 -8
- package/dist/types/key.d.ts.map +1 -1
- package/dist/types/lock.cjs +33 -13
- package/dist/types/lock.cjs.map +1 -1
- package/dist/types/lock.d.cts +14 -0
- package/dist/types/lock.d.cts.map +1 -1
- package/dist/types/lock.d.ts +14 -0
- package/dist/types/lock.d.ts.map +1 -1
- package/dist/types/lock.js +34 -15
- package/dist/types/lock.js.map +1 -1
- package/dist/types/notifications.cjs +44 -22
- package/dist/types/notifications.cjs.map +1 -1
- package/dist/types/notifications.d.cts +5 -3
- package/dist/types/notifications.d.cts.map +1 -1
- package/dist/types/notifications.d.ts +5 -3
- package/dist/types/notifications.d.ts.map +1 -1
- package/dist/types/notifications.js +45 -23
- package/dist/types/notifications.js.map +1 -1
- package/dist/types/partner.cjs +32 -0
- package/dist/types/partner.cjs.map +1 -0
- package/dist/types/partner.d.cts +394 -0
- package/dist/types/partner.d.cts.map +1 -0
- package/dist/types/partner.d.ts +394 -0
- package/dist/types/partner.d.ts.map +1 -0
- package/dist/types/partner.js +29 -0
- package/dist/types/partner.js.map +1 -0
- package/dist/types/upload.d.cts +53 -6
- package/dist/types/upload.d.cts.map +1 -1
- package/dist/types/upload.d.ts +53 -6
- package/dist/types/upload.d.ts.map +1 -1
- package/dist/upload/abort-scope.cjs +46 -0
- package/dist/upload/abort-scope.cjs.map +1 -0
- package/dist/upload/abort-scope.d.cts +26 -0
- package/dist/upload/abort-scope.d.cts.map +1 -0
- package/dist/upload/abort-scope.d.ts +26 -0
- package/dist/upload/abort-scope.d.ts.map +1 -0
- package/dist/upload/abort-scope.js +45 -0
- package/dist/upload/abort-scope.js.map +1 -0
- package/dist/upload/cancel.cjs +183 -8
- package/dist/upload/cancel.cjs.map +1 -1
- package/dist/upload/cancel.d.cts +110 -2
- package/dist/upload/cancel.d.cts.map +1 -1
- package/dist/upload/cancel.d.ts +110 -2
- package/dist/upload/cancel.d.ts.map +1 -1
- package/dist/upload/cancel.js +178 -9
- package/dist/upload/cancel.js.map +1 -1
- package/dist/upload/concurrency.cjs +79 -52
- package/dist/upload/concurrency.cjs.map +1 -1
- package/dist/upload/concurrency.js +79 -53
- package/dist/upload/concurrency.js.map +1 -1
- package/dist/upload/finish.cjs +54 -0
- package/dist/upload/finish.cjs.map +1 -0
- package/dist/upload/finish.d.cts +26 -0
- package/dist/upload/finish.d.cts.map +1 -0
- package/dist/upload/finish.d.ts +26 -0
- package/dist/upload/finish.d.ts.map +1 -0
- package/dist/upload/finish.js +54 -0
- package/dist/upload/finish.js.map +1 -0
- package/dist/upload/large.cjs +305 -218
- package/dist/upload/large.cjs.map +1 -1
- package/dist/upload/large.d.cts +90 -9
- package/dist/upload/large.d.cts.map +1 -1
- package/dist/upload/large.d.ts +90 -9
- package/dist/upload/large.d.ts.map +1 -1
- package/dist/upload/large.js +305 -218
- package/dist/upload/large.js.map +1 -1
- package/dist/upload/options.cjs +28 -0
- package/dist/upload/options.cjs.map +1 -0
- package/dist/upload/options.d.cts +116 -0
- package/dist/upload/options.d.cts.map +1 -0
- package/dist/upload/options.d.ts +116 -0
- package/dist/upload/options.d.ts.map +1 -0
- package/dist/upload/options.js +27 -0
- package/dist/upload/options.js.map +1 -0
- package/dist/upload/resume.cjs +334 -33
- package/dist/upload/resume.cjs.map +1 -1
- package/dist/upload/resume.d.cts +84 -12
- package/dist/upload/resume.d.cts.map +1 -1
- package/dist/upload/resume.d.ts +84 -12
- package/dist/upload/resume.d.ts.map +1 -1
- package/dist/upload/resume.js +331 -33
- package/dist/upload/resume.js.map +1 -1
- package/dist/upload/retry.cjs +191 -0
- package/dist/upload/retry.cjs.map +1 -0
- package/dist/upload/retry.d.cts +127 -0
- package/dist/upload/retry.d.cts.map +1 -0
- package/dist/upload/retry.d.ts +127 -0
- package/dist/upload/retry.d.ts.map +1 -0
- package/dist/upload/retry.js +187 -0
- package/dist/upload/retry.js.map +1 -0
- package/dist/upload/single.cjs +69 -44
- package/dist/upload/single.cjs.map +1 -1
- package/dist/upload/single.d.cts +8 -1
- package/dist/upload/single.d.cts.map +1 -1
- package/dist/upload/single.d.ts +8 -1
- package/dist/upload/single.d.ts.map +1 -1
- package/dist/upload/single.js +69 -44
- package/dist/upload/single.js.map +1 -1
- package/dist/upload/stream.cjs +319 -193
- package/dist/upload/stream.cjs.map +1 -1
- package/dist/upload/stream.d.cts +9 -1
- package/dist/upload/stream.d.cts.map +1 -1
- package/dist/upload/stream.d.ts +9 -1
- package/dist/upload/stream.d.ts.map +1 -1
- package/dist/upload/stream.js +318 -192
- package/dist/upload/stream.js.map +1 -1
- package/dist/util/abort.cjs +114 -0
- package/dist/util/abort.cjs.map +1 -0
- package/dist/util/abort.d.cts +65 -0
- package/dist/util/abort.d.cts.map +1 -0
- package/dist/util/abort.d.ts +65 -0
- package/dist/util/abort.d.ts.map +1 -0
- package/dist/util/abort.js +109 -0
- package/dist/util/abort.js.map +1 -0
- package/dist/util/best-effort.cjs +42 -8
- package/dist/util/best-effort.cjs.map +1 -1
- package/dist/util/best-effort.d.cts +2 -1
- package/dist/util/best-effort.d.cts.map +1 -1
- package/dist/util/best-effort.d.ts +2 -1
- package/dist/util/best-effort.d.ts.map +1 -1
- package/dist/util/best-effort.js +43 -9
- package/dist/util/best-effort.js.map +1 -1
- package/dist/util/bytes.cjs +23 -0
- package/dist/util/bytes.cjs.map +1 -0
- package/dist/util/bytes.d.cts +15 -0
- package/dist/util/bytes.d.cts.map +1 -0
- package/dist/util/bytes.d.ts +15 -0
- package/dist/util/bytes.d.ts.map +1 -0
- package/dist/util/bytes.js +23 -0
- package/dist/util/bytes.js.map +1 -0
- package/dist/util/crypto.cjs +68 -0
- package/dist/util/crypto.cjs.map +1 -0
- package/dist/util/crypto.d.cts +26 -0
- package/dist/util/crypto.d.cts.map +1 -0
- package/dist/util/crypto.d.ts +26 -0
- package/dist/util/crypto.d.ts.map +1 -0
- package/dist/util/crypto.js +66 -0
- package/dist/util/crypto.js.map +1 -0
- package/dist/util/defaults.cjs +28 -9
- package/dist/util/defaults.cjs.map +1 -1
- package/dist/util/defaults.d.cts +2 -0
- package/dist/util/defaults.d.cts.map +1 -1
- package/dist/util/defaults.d.ts +2 -0
- package/dist/util/defaults.d.ts.map +1 -1
- package/dist/util/defaults.js +28 -11
- package/dist/util/defaults.js.map +1 -1
- package/dist/util/error-reason.cjs +41 -0
- package/dist/util/error-reason.cjs.map +1 -0
- package/dist/util/error-reason.d.cts +9 -0
- package/dist/util/error-reason.d.cts.map +1 -0
- package/dist/util/error-reason.d.ts +9 -0
- package/dist/util/error-reason.d.ts.map +1 -0
- package/dist/util/error-reason.js +41 -0
- package/dist/util/error-reason.js.map +1 -0
- package/dist/util/http.cjs +23 -0
- package/dist/util/http.cjs.map +1 -0
- package/dist/util/http.d.cts +13 -0
- package/dist/util/http.d.cts.map +1 -0
- package/dist/util/http.d.ts +13 -0
- package/dist/util/http.d.ts.map +1 -0
- package/dist/util/http.js +23 -0
- package/dist/util/http.js.map +1 -0
- package/dist/util/md5.cjs +211 -0
- package/dist/util/md5.cjs.map +1 -0
- package/dist/util/md5.d.cts +43 -0
- package/dist/util/md5.d.cts.map +1 -0
- package/dist/util/md5.d.ts +43 -0
- package/dist/util/md5.d.ts.map +1 -0
- package/dist/util/md5.js +209 -0
- package/dist/util/md5.js.map +1 -0
- package/dist/util/normalize.cjs +66 -7
- package/dist/util/normalize.cjs.map +1 -1
- package/dist/util/normalize.d.cts +7 -7
- package/dist/util/normalize.d.cts.map +1 -1
- package/dist/util/normalize.d.ts +7 -7
- package/dist/util/normalize.d.ts.map +1 -1
- package/dist/util/normalize.js +67 -10
- package/dist/util/normalize.js.map +1 -1
- package/dist/util/paginator.cjs +69 -14
- package/dist/util/paginator.cjs.map +1 -1
- package/dist/util/paginator.d.cts +3 -1
- package/dist/util/paginator.d.cts.map +1 -1
- package/dist/util/paginator.d.ts +3 -1
- package/dist/util/paginator.d.ts.map +1 -1
- package/dist/util/paginator.js +70 -16
- package/dist/util/paginator.js.map +1 -1
- package/dist/util/plan-ranges.cjs +47 -22
- package/dist/util/plan-ranges.cjs.map +1 -1
- package/dist/util/plan-ranges.js +48 -24
- package/dist/util/plan-ranges.js.map +1 -1
- package/dist/util/sha1.cjs +27 -0
- package/dist/util/sha1.cjs.map +1 -0
- package/dist/util/sha1.d.cts +17 -0
- package/dist/util/sha1.d.cts.map +1 -0
- package/dist/util/sha1.d.ts +17 -0
- package/dist/util/sha1.d.ts.map +1 -0
- package/dist/util/sha1.js +26 -0
- package/dist/util/sha1.js.map +1 -0
- package/dist/util/text-codec.cjs +29 -5
- package/dist/util/text-codec.cjs.map +1 -1
- package/dist/util/text-codec.js +30 -7
- package/dist/util/text-codec.js.map +1 -1
- package/dist/util/to-error.cjs +18 -4
- package/dist/util/to-error.cjs.map +1 -1
- package/dist/util/to-error.js +19 -5
- package/dist/util/to-error.js.map +1 -1
- package/dist/version.cjs +17 -5
- package/dist/version.cjs.map +1 -1
- package/dist/version.d.cts +3 -3
- package/dist/version.d.ts +3 -3
- package/dist/version.js +19 -6
- package/dist/version.js.map +1 -1
- package/package.json +45 -20
- package/dist/auth/index.cjs.map +0 -1
- package/dist/auth/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/notifications/index.cjs.map +0 -1
- package/dist/notifications/index.js.map +0 -1
- package/dist/package.json.cjs +0 -9
- package/dist/package.json.cjs.map +0 -1
- package/dist/package.json.js +0 -9
- package/dist/package.json.js.map +0 -1
- package/dist/streams/index.cjs.map +0 -1
- package/dist/streams/index.js.map +0 -1
- package/dist/sync/index.cjs.map +0 -1
- package/dist/sync/index.js.map +0 -1
package/dist/simulator/index.js
CHANGED
|
@@ -1,1560 +1,3113 @@
|
|
|
1
|
-
import { sha1Hex } from "../streams/hash.js";
|
|
2
|
-
import { Capability } from "../types/auth.js";
|
|
3
|
-
import { BucketRetentionMode } from "../types/bucket.js";
|
|
4
|
-
import { EncryptionMode } from "../types/encryption.js";
|
|
5
1
|
import { FileAction } from "../types/file.js";
|
|
6
2
|
import { accountId, bucketId, fileId } from "../types/ids.js";
|
|
7
|
-
import {
|
|
3
|
+
import { utf8Decoder, utf8Encoder } from "../util/text-codec.js";
|
|
4
|
+
import { encodeFileName } from "../raw/encoding.js";
|
|
5
|
+
import { DownloadClientUnauthorizedToReadMarker, DownloadHeaderName } from "../types/download.js";
|
|
6
|
+
import { md5Base64, md5Base64Sync } from "../util/md5.js";
|
|
7
|
+
import { EncryptionAlgorithm, EncryptionMode } from "../types/encryption.js";
|
|
8
|
+
import { LegalHoldValue, RetentionMode } from "../types/lock.js";
|
|
9
|
+
import { hexEncode, hmacSha256 } from "../util/crypto.js";
|
|
10
|
+
import { sha1Hex } from "../streams/hash.js";
|
|
11
|
+
import { BucketRetentionMode } from "../types/bucket.js";
|
|
8
12
|
import { toError } from "../util/to-error.js";
|
|
13
|
+
import { b2Url, isB2ApiVersion } from "../raw/url.js";
|
|
14
|
+
import { Capability } from "../types/auth.js";
|
|
15
|
+
import { PartnerSimulator, isPartnerQueryEndpoint } from "./partner.js";
|
|
9
16
|
import { missingCapabilitiesFor } from "./capabilities.js";
|
|
10
|
-
import {
|
|
11
|
-
import { BUCKET_INFO_MAX_KEYS, BUCKET_INFO_VALUE_MAX,
|
|
17
|
+
import { BUCKET_NAME_MAX, BUCKET_NAME_MIN, FILE_NAME_MAX_BYTES } from "../internal/b2-naming.js";
|
|
18
|
+
import { BUCKET_INFO_MAX_KEYS, BUCKET_INFO_VALUE_MAX, DOWNLOAD_AUTH_DURATION_MAX_SECONDS, DOWNLOAD_AUTH_DURATION_MIN_SECONDS, FILE_INFO_TOTAL_MAX, FILE_INFO_VALUE_MAX, KEY_NAME_MAX, KEY_NAME_MIN, LIST_ENDPOINT_CAPS, normalizeCreateKeyCapabilities, validateBucketInfo, validateBucketName, validateBucketTypes, validateCorsRules, validateCreateKeyCapabilities, validateCreateKeyName, validateDefaultRetention, validateDownloadAuthorizationDuration, validateDownloadAuthorizationPrefix, validateFileInfo, validateFileName, validateLifecycleRules, validateMaxCount, validateNotificationRules, validateReplicationConfiguration } from "./validation.js";
|
|
19
|
+
//#region src/simulator/index.ts
|
|
20
|
+
var UPLOAD_TOKEN_SIGNING_KEY = [
|
|
21
|
+
"b2 sdk typescript",
|
|
22
|
+
"simulator upload authorization",
|
|
23
|
+
"v1"
|
|
24
|
+
].join(":");
|
|
25
|
+
var SIMULATOR_MASTER_APPLICATION_KEY_ID = "master-key-id";
|
|
26
|
+
var SIMULATOR_MASTER_APPLICATION_KEY = "master-key";
|
|
27
|
+
var SIMULATOR_TEST_APPLICATION_KEY_ID = "test-key-id";
|
|
28
|
+
var SIMULATOR_TEST_APPLICATION_KEY = "test-key";
|
|
29
|
+
var SIMULATOR_APPLICATION_KEY_ID_PREFIX = "sim_key_";
|
|
30
|
+
var SIMULATOR_MASTER_CAPABILITIES = [
|
|
31
|
+
Capability.ListBuckets,
|
|
32
|
+
Capability.ListAllBucketNames,
|
|
33
|
+
Capability.ReadBuckets,
|
|
34
|
+
Capability.WriteBuckets,
|
|
35
|
+
Capability.DeleteBuckets,
|
|
36
|
+
Capability.ReadBucketEncryption,
|
|
37
|
+
Capability.WriteBucketEncryption,
|
|
38
|
+
Capability.ReadBucketReplications,
|
|
39
|
+
Capability.WriteBucketReplications,
|
|
40
|
+
Capability.ReadBucketNotifications,
|
|
41
|
+
Capability.WriteBucketNotifications,
|
|
42
|
+
Capability.ReadBucketLogging,
|
|
43
|
+
Capability.WriteBucketLogging,
|
|
44
|
+
Capability.ReadBucketLifecycleRules,
|
|
45
|
+
Capability.WriteBucketLifecycleRules,
|
|
46
|
+
Capability.ListFiles,
|
|
47
|
+
Capability.ReadFiles,
|
|
48
|
+
Capability.WriteFiles,
|
|
49
|
+
Capability.DeleteFiles,
|
|
50
|
+
Capability.ListKeys,
|
|
51
|
+
Capability.WriteKeys,
|
|
52
|
+
Capability.DeleteKeys,
|
|
53
|
+
Capability.ShareFiles
|
|
54
|
+
];
|
|
55
|
+
function apiPathParts(path) {
|
|
56
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
57
|
+
const candidate = segments.at(-2);
|
|
58
|
+
return {
|
|
59
|
+
endpoint: segments.at(-1) ?? "",
|
|
60
|
+
version: candidate !== void 0 && isB2ApiVersion(candidate) ? candidate : "v3"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function parseBasicAuthorizationHeader(authzHeader) {
|
|
64
|
+
if (!authzHeader?.startsWith("Basic ")) return null;
|
|
65
|
+
const decoded = (() => {
|
|
66
|
+
try {
|
|
67
|
+
return atob(authzHeader.slice(6));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
if (decoded === null) return null;
|
|
73
|
+
const idx = decoded.indexOf(":");
|
|
74
|
+
if (idx <= 0 || idx === decoded.length - 1) return null;
|
|
75
|
+
return {
|
|
76
|
+
applicationKeyId: decoded.slice(0, idx),
|
|
77
|
+
applicationKey: decoded.slice(idx + 1)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
var B2_MIN_PART_NUMBER = 1;
|
|
81
|
+
var B2_MAX_PART_NUMBER = 1e4;
|
|
82
|
+
function base64UrlEncode(bytes) {
|
|
83
|
+
let binary = "";
|
|
84
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
85
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
86
|
+
}
|
|
87
|
+
function base64UrlDecode(encoded) {
|
|
88
|
+
const base64 = encoded.replaceAll("-", "+").replaceAll("_", "/");
|
|
89
|
+
const padding = (4 - base64.length % 4) % 4;
|
|
90
|
+
try {
|
|
91
|
+
const binary = atob(base64.padEnd(base64.length + padding, "="));
|
|
92
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function timingSafeStringEqual(left, right) {
|
|
98
|
+
const max = Math.max(left.length, right.length);
|
|
99
|
+
let mismatch = left.length === right.length ? 0 : 1;
|
|
100
|
+
for (let i = 0; i < max; i++) mismatch |= (left.charCodeAt(i) || 0) ^ (right.charCodeAt(i) || 0);
|
|
101
|
+
return mismatch === 0;
|
|
102
|
+
}
|
|
103
|
+
var MS_PER_DAY = 864e5;
|
|
104
|
+
var MAX_JS_DATE_MS = 864e13;
|
|
105
|
+
function isValidRetentionTimestamp(timestamp, now = Date.now()) {
|
|
106
|
+
return Number.isFinite(timestamp) && timestamp <= MAX_JS_DATE_MS && timestamp > now;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse an RFC 7233 `Range` header value into inclusive start/end byte
|
|
110
|
+
* offsets clamped to the file size. Supports closed (`bytes=0-999`),
|
|
111
|
+
* open-ended (`bytes=1000-`), and suffix (`bytes=-500`) forms.
|
|
112
|
+
*
|
|
113
|
+
* @param header - The raw header value (e.g. `'bytes=0-999'`).
|
|
114
|
+
* @param totalSize - The full file size in bytes (for clamping + suffix).
|
|
115
|
+
*
|
|
116
|
+
* @returns A {@link RangeParseResult} tagged with how the simulator
|
|
117
|
+
* should respond: serve the range, send 416, or ignore the header.
|
|
118
|
+
*
|
|
119
|
+
* @see https://www.rfc-editor.org/rfc/rfc7233#section-2.1
|
|
120
|
+
*/
|
|
12
121
|
function parseRangeHeader(header, totalSize) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
122
|
+
const m = header.match(/^bytes=(\d*)-(\d*)$/);
|
|
123
|
+
if (!m) return { kind: "malformed" };
|
|
124
|
+
const [, startStr, endStr] = m;
|
|
125
|
+
const hasStart = startStr !== "";
|
|
126
|
+
const hasEnd = endStr !== "";
|
|
127
|
+
if (!hasStart && !hasEnd) return { kind: "malformed" };
|
|
128
|
+
if (totalSize === 0) return { kind: "unsatisfiable" };
|
|
129
|
+
let start;
|
|
130
|
+
let end;
|
|
131
|
+
if (!hasStart) {
|
|
132
|
+
const suffixLen = Number.parseInt(endStr ?? "0", 10);
|
|
133
|
+
if (!Number.isFinite(suffixLen) || suffixLen <= 0) return { kind: "malformed" };
|
|
134
|
+
start = Math.max(0, totalSize - suffixLen);
|
|
135
|
+
end = totalSize - 1;
|
|
136
|
+
} else if (!hasEnd) {
|
|
137
|
+
start = Number.parseInt(startStr ?? "0", 10);
|
|
138
|
+
end = totalSize - 1;
|
|
139
|
+
} else {
|
|
140
|
+
start = Number.parseInt(startStr ?? "0", 10);
|
|
141
|
+
end = Number.parseInt(endStr ?? "0", 10);
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start > end) return { kind: "malformed" };
|
|
144
|
+
if (start >= totalSize) return { kind: "unsatisfiable" };
|
|
145
|
+
end = Math.min(end, totalSize - 1);
|
|
146
|
+
return {
|
|
147
|
+
kind: "ok",
|
|
148
|
+
start,
|
|
149
|
+
end
|
|
150
|
+
};
|
|
40
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Parse `X-Bz-Info-*` headers (lowercased Map keys) into a plain
|
|
154
|
+
* fileInfo record. Mirrors the SDK's `parseFileInfoHeaders` in
|
|
155
|
+
* `raw/encoding.ts` but operates on a `Record<string, string>` rather
|
|
156
|
+
* than a `Headers` object so the simulator can reuse the same
|
|
157
|
+
* extraction logic without converting back to `Headers`.
|
|
158
|
+
*
|
|
159
|
+
* @param headers - Lowercased header map.
|
|
160
|
+
*
|
|
161
|
+
* @returns Plain `Record<string, string>` of `fileInfo` keys/values.
|
|
162
|
+
*/
|
|
41
163
|
function parseFileInfoHeaders(headers) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
164
|
+
const info = {};
|
|
165
|
+
const prefix = "x-bz-info-";
|
|
166
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
167
|
+
if (!key.startsWith(prefix)) continue;
|
|
168
|
+
const fileInfoKey = decodeURIComponent(key.slice(10));
|
|
169
|
+
info[fileInfoKey] = decodeURIComponent(value);
|
|
170
|
+
}
|
|
171
|
+
return info;
|
|
172
|
+
}
|
|
173
|
+
function decodeHeaderValue(value) {
|
|
174
|
+
try {
|
|
175
|
+
return decodeURIComponent(value);
|
|
176
|
+
} catch {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Compares B2 file names with deterministic JS string order, not locale collation.
|
|
182
|
+
*
|
|
183
|
+
* @param a - First file name.
|
|
184
|
+
* @param b - Second file name.
|
|
185
|
+
*
|
|
186
|
+
* @returns `-1` when `a` sorts first, `1` when `b` sorts first, otherwise `0`.
|
|
187
|
+
*/
|
|
188
|
+
function compareB2FileNames(a, b) {
|
|
189
|
+
if (a < b) return -1;
|
|
190
|
+
if (a > b) return 1;
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
var DOWNLOAD_AUTH_TOKEN_BYTES = 32;
|
|
194
|
+
var DOWNLOAD_AUTH_TOKEN_PREFIX = "sim_dl_auth_";
|
|
195
|
+
var DOWNLOAD_AUTH_PURGE_BATCH_SIZE = 128;
|
|
196
|
+
var DOWNLOAD_RESPONSE_OVERRIDE_PARAMS = [
|
|
197
|
+
"b2ContentDisposition",
|
|
198
|
+
"b2ContentLanguage",
|
|
199
|
+
"b2ContentEncoding",
|
|
200
|
+
"b2ContentType",
|
|
201
|
+
"b2CacheControl",
|
|
202
|
+
"b2Expires"
|
|
203
|
+
];
|
|
204
|
+
var DOWNLOAD_RESPONSE_OVERRIDE_HEADERS = {
|
|
205
|
+
b2ContentDisposition: DownloadHeaderName.ContentDisposition,
|
|
206
|
+
b2ContentLanguage: DownloadHeaderName.ContentLanguage,
|
|
207
|
+
b2ContentEncoding: DownloadHeaderName.ContentEncoding,
|
|
208
|
+
b2ContentType: DownloadHeaderName.ContentType,
|
|
209
|
+
b2CacheControl: DownloadHeaderName.CacheControl,
|
|
210
|
+
b2Expires: DownloadHeaderName.Expires
|
|
211
|
+
};
|
|
212
|
+
function randomDownloadAuthorizationToken() {
|
|
213
|
+
const crypto = globalThis.crypto;
|
|
214
|
+
if (typeof crypto?.getRandomValues !== "function") throw new Error("Download authorization tokens require globalThis.crypto.getRandomValues");
|
|
215
|
+
const bytes = new Uint8Array(DOWNLOAD_AUTH_TOKEN_BYTES);
|
|
216
|
+
crypto.getRandomValues(bytes);
|
|
217
|
+
return `${DOWNLOAD_AUTH_TOKEN_PREFIX}${hexEncode(bytes)}`;
|
|
218
|
+
}
|
|
219
|
+
function pickDownloadResponseOverrides(req) {
|
|
220
|
+
const overrides = {};
|
|
221
|
+
for (const param of DOWNLOAD_RESPONSE_OVERRIDE_PARAMS) {
|
|
222
|
+
const value = req[param];
|
|
223
|
+
if (typeof value === "string") overrides[param] = value;
|
|
224
|
+
}
|
|
225
|
+
return overrides;
|
|
226
|
+
}
|
|
227
|
+
function validateDownloadResponseOverrides(req) {
|
|
228
|
+
for (const param of DOWNLOAD_RESPONSE_OVERRIDE_PARAMS) {
|
|
229
|
+
const value = req[param];
|
|
230
|
+
if (value === void 0 || typeof value === "string") continue;
|
|
231
|
+
return {
|
|
232
|
+
code: "bad_request",
|
|
233
|
+
message: `${param} must be a string`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function normalizeKeyBucketIds(req) {
|
|
239
|
+
return req.bucketIds === void 0 || req.bucketIds === null ? null : Object.freeze([...req.bucketIds]);
|
|
240
|
+
}
|
|
241
|
+
function singleBucketId(bucketIds) {
|
|
242
|
+
return bucketIds?.length === 1 ? bucketIds[0] ?? null : null;
|
|
243
|
+
}
|
|
244
|
+
function cloneBucketIds(bucketIds) {
|
|
245
|
+
return bucketIds === null ? null : [...bucketIds];
|
|
246
|
+
}
|
|
247
|
+
function cloneCapabilities(capabilities) {
|
|
248
|
+
return [...capabilities];
|
|
249
|
+
}
|
|
250
|
+
function isImplicitStorageMasterCredential(credentials) {
|
|
251
|
+
return credentials.applicationKeyId === SIMULATOR_MASTER_APPLICATION_KEY_ID && credentials.applicationKey === SIMULATOR_MASTER_APPLICATION_KEY || credentials.applicationKeyId === SIMULATOR_TEST_APPLICATION_KEY_ID && credentials.applicationKey === SIMULATOR_TEST_APPLICATION_KEY;
|
|
252
|
+
}
|
|
253
|
+
function isImplicitPartnerMasterCredential(credentials) {
|
|
254
|
+
return credentials.applicationKeyId === SIMULATOR_MASTER_APPLICATION_KEY_ID && credentials.applicationKey === SIMULATOR_MASTER_APPLICATION_KEY;
|
|
255
|
+
}
|
|
256
|
+
function isSimulatorApplicationKeyId(applicationKeyId) {
|
|
257
|
+
return applicationKeyId.startsWith(SIMULATOR_APPLICATION_KEY_ID_PREFIX);
|
|
258
|
+
}
|
|
259
|
+
function hasOwnField(body, field) {
|
|
260
|
+
return typeof body === "object" && body !== null && Object.hasOwn(body, field);
|
|
261
|
+
}
|
|
262
|
+
function requestStringField(body, field) {
|
|
263
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
264
|
+
const value = body[field];
|
|
265
|
+
return typeof value === "string" ? value : void 0;
|
|
266
|
+
}
|
|
267
|
+
function requestRecord(body) {
|
|
268
|
+
return typeof body === "object" && body !== null && !Array.isArray(body) ? body : null;
|
|
269
|
+
}
|
|
270
|
+
function replicationDestinationBucketIds(body) {
|
|
271
|
+
const rules = requestRecord(requestRecord(requestRecord(body)?.["replicationConfiguration"])?.["asReplicationSource"])?.["replicationRules"];
|
|
272
|
+
if (!Array.isArray(rules)) return [];
|
|
273
|
+
const bucketIds = [];
|
|
274
|
+
for (const rule of rules) {
|
|
275
|
+
const destinationBucketId = requestRecord(rule)?.["destinationBucketId"];
|
|
276
|
+
if (typeof destinationBucketId === "string") bucketIds.push(destinationBucketId);
|
|
277
|
+
}
|
|
278
|
+
return [...new Set(bucketIds)];
|
|
279
|
+
}
|
|
280
|
+
function normalizeReplicationConfiguration(config) {
|
|
281
|
+
return {
|
|
282
|
+
asReplicationSource: config.asReplicationSource ?? null,
|
|
283
|
+
asReplicationDestination: config.asReplicationDestination ?? null
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function validateBucketConfigurationFields(fields, options) {
|
|
287
|
+
if (fields.bucketInfo !== void 0) {
|
|
288
|
+
const infoError = validateBucketInfo(fields.bucketInfo);
|
|
289
|
+
if (infoError) return infoError;
|
|
290
|
+
}
|
|
291
|
+
if (fields.corsRules !== void 0) {
|
|
292
|
+
const corsError = validateCorsRules(fields.corsRules);
|
|
293
|
+
if (corsError) return corsError;
|
|
294
|
+
}
|
|
295
|
+
if (fields.lifecycleRules !== void 0) {
|
|
296
|
+
const lifecycleError = validateLifecycleRules(fields.lifecycleRules);
|
|
297
|
+
if (lifecycleError) return lifecycleError;
|
|
298
|
+
}
|
|
299
|
+
if (fields.replicationConfiguration !== void 0) {
|
|
300
|
+
const replicationError = validateReplicationConfiguration(fields.replicationConfiguration);
|
|
301
|
+
if (replicationError) return replicationError;
|
|
302
|
+
}
|
|
303
|
+
if (fields.defaultRetention !== void 0) {
|
|
304
|
+
const retentionError = validateDefaultRetention(fields.defaultRetention);
|
|
305
|
+
if (retentionError) return retentionError;
|
|
306
|
+
if (requestRecord(fields.defaultRetention)?.["mode"] !== BucketRetentionMode.None && !options.objectLockEnabled) return {
|
|
307
|
+
code: "file_lock_not_enabled",
|
|
308
|
+
message: "Bucket must have Object Lock enabled to set defaultRetention"
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function nextObjectLockEnabled(current, requested) {
|
|
314
|
+
if (requested === void 0) return current;
|
|
315
|
+
if (typeof requested !== "boolean") return {
|
|
316
|
+
code: "bad_request",
|
|
317
|
+
message: "fileLockEnabled must be a boolean"
|
|
318
|
+
};
|
|
319
|
+
if (current && requested === false) return {
|
|
320
|
+
code: "file_lock_conflict",
|
|
321
|
+
message: "Object Lock cannot be disabled"
|
|
322
|
+
};
|
|
323
|
+
return current || requested;
|
|
50
324
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
notificationRules = /* @__PURE__ */ new Map();
|
|
57
|
-
minimumPartSize;
|
|
58
|
-
recommendedPartSize;
|
|
59
|
-
faults = [];
|
|
60
|
-
nextFaultId = 1;
|
|
61
|
-
onWebhookDeliver;
|
|
62
|
-
onReplicate;
|
|
63
|
-
onHookError;
|
|
64
|
-
strictAuth;
|
|
65
|
-
authTokenTtlMs;
|
|
66
|
-
/**
|
|
67
|
-
* Issued auth tokens with their associated grant scope + expiry. In
|
|
68
|
-
* permissive mode (`strictAuth: false`) this is still populated by
|
|
69
|
-
* `authorize` but never consulted on subsequent requests. In strict
|
|
70
|
-
* mode each request looks up its `Authorization` header here.
|
|
71
|
-
*/
|
|
72
|
-
issuedTokens = /* @__PURE__ */ new Map();
|
|
73
|
-
/**
|
|
74
|
-
* Virtual-clock offset applied to `Date.now()` for expiry checks.
|
|
75
|
-
* Defaults to 0. Tests advance via {@link advanceTime} to fast-forward
|
|
76
|
-
* past auth-token expiry without sleeping.
|
|
77
|
-
*/
|
|
78
|
-
clockOffsetMs = 0;
|
|
79
|
-
/**
|
|
80
|
-
* Per-instance monotonic counter used to mint realistic-looking
|
|
81
|
-
* IDs (`b2_bucket_<24-hex>`, `4_z<24-hex>`, etc.) and auth tokens.
|
|
82
|
-
* Module-global previously, which leaked state across `B2Simulator`
|
|
83
|
-
* instances in the same Vitest worker — concurrent tests could see
|
|
84
|
-
* colliding tokens. Per-instance ensures determinism within a single
|
|
85
|
-
* simulator's lifetime.
|
|
86
|
-
*/
|
|
87
|
-
nextId = 1;
|
|
88
|
-
/**
|
|
89
|
-
* Last-issued upload timestamp. The simulator enforces strict
|
|
90
|
-
* monotonicity so version ordering is deterministic for tests; if
|
|
91
|
-
* two writes hit the same `Date.now()` millisecond, the second
|
|
92
|
-
* receives `lastTimestamp + 1`.
|
|
93
|
-
*/
|
|
94
|
-
lastTimestamp = 0;
|
|
95
|
-
/**
|
|
96
|
-
* Outstanding fire-and-forget hook invocations. Tracked so tests
|
|
97
|
-
* (and future {@link flushHooks} callers) can wait for every
|
|
98
|
-
* dispatched `onWebhookDeliver` / `onReplicate` callback to settle
|
|
99
|
-
* before asserting against observable side effects. The microtask
|
|
100
|
-
* dance in fidelity tests was previously brittle: positive cases
|
|
101
|
-
* flushed twice, negative cases flushed once, and any addition to
|
|
102
|
-
* the hook dispatch chain quietly broke the negative path.
|
|
103
|
-
*/
|
|
104
|
-
pendingHooks = /* @__PURE__ */ new Set();
|
|
105
|
-
/**
|
|
106
|
-
* Constructs a new in-memory B2 simulator.
|
|
107
|
-
* @param options - Optional simulator overrides. See {@link B2SimulatorOptions}.
|
|
108
|
-
*/
|
|
109
|
-
constructor(options = {}) {
|
|
110
|
-
this.minimumPartSize = options.minimumPartSize ?? 5e6;
|
|
111
|
-
this.recommendedPartSize = options.recommendedPartSize ?? 1e8;
|
|
112
|
-
if (options.onWebhookDeliver !== void 0) this.onWebhookDeliver = options.onWebhookDeliver;
|
|
113
|
-
if (options.onReplicate !== void 0) this.onReplicate = options.onReplicate;
|
|
114
|
-
if (options.onHookError !== void 0) this.onHookError = options.onHookError;
|
|
115
|
-
this.strictAuth = options.strictAuth ?? false;
|
|
116
|
-
this.authTokenTtlMs = options.authTokenTtlMs ?? 24 * 60 * 60 * 1e3;
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Advance the simulator's virtual clock by `ms` milliseconds. Used in
|
|
120
|
-
* conjunction with `strictAuth: true` + a finite `authTokenTtlMs` to
|
|
121
|
-
* force token expiry without `setTimeout`-based delays.
|
|
122
|
-
*
|
|
123
|
-
* @param ms - Milliseconds to advance. Negative values rewind (rarely useful).
|
|
124
|
-
*/
|
|
125
|
-
advanceTime(ms) {
|
|
126
|
-
this.clockOffsetMs += ms;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Current simulator time. Equal to `Date.now() + clockOffsetMs`.
|
|
130
|
-
*
|
|
131
|
-
* @returns Unix milliseconds.
|
|
132
|
-
*/
|
|
133
|
-
now() {
|
|
134
|
-
return Date.now() + this.clockOffsetMs;
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Generate a deterministic-but-realistic-looking B2 identifier of the
|
|
138
|
-
* shape `<prefix>_<24-hex-counter>`, which approximates the visual
|
|
139
|
-
* width of real B2 wire IDs. The counter is monotonic per instance
|
|
140
|
-
* so test fixtures are deterministic across runs of the same
|
|
141
|
-
* simulator.
|
|
142
|
-
*
|
|
143
|
-
* @param prefix - B2-style prefix (`'b2_bucket'`, `'4_z'`, etc.).
|
|
144
|
-
*
|
|
145
|
-
* @returns A simulator-issued identifier that looks like a B2 wire ID.
|
|
146
|
-
*/
|
|
147
|
-
genId(prefix) {
|
|
148
|
-
const n = this.nextId++;
|
|
149
|
-
return `${prefix}_${n.toString(16).padStart(24, "0")}`;
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Return a strictly-increasing upload timestamp, even when multiple
|
|
153
|
-
* writes land in the same `Date.now()` millisecond. Tests rely on
|
|
154
|
-
* `uploadTimestamp` ordering to discriminate file versions; ties from
|
|
155
|
-
* coarse system-clock resolution would otherwise make version
|
|
156
|
-
* selection nondeterministic.
|
|
157
|
-
*
|
|
158
|
-
* @returns Unix milliseconds.
|
|
159
|
-
*/
|
|
160
|
-
monotonicTimestamp() {
|
|
161
|
-
const now = Date.now();
|
|
162
|
-
if (now <= this.lastTimestamp) {
|
|
163
|
-
this.lastTimestamp += 1;
|
|
164
|
-
} else {
|
|
165
|
-
this.lastTimestamp = now;
|
|
166
|
-
}
|
|
167
|
-
return this.lastTimestamp;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Wait for every fire-and-forget hook (`onWebhookDeliver`,
|
|
171
|
-
* `onReplicate`) currently in flight to settle. Use in tests after
|
|
172
|
-
* an upload/copy/finish-large-file call to deterministically observe
|
|
173
|
-
* hook side effects without microtask-flush guesswork.
|
|
174
|
-
*
|
|
175
|
-
* @returns A promise that resolves once every pending hook callback
|
|
176
|
-
* has either resolved or rejected.
|
|
177
|
-
*/
|
|
178
|
-
async flushHooks() {
|
|
179
|
-
while (this.pendingHooks.size > 0) {
|
|
180
|
-
const snapshot = [...this.pendingHooks];
|
|
181
|
-
await Promise.allSettled(snapshot);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* Authorize a request against the strict-auth bookkeeping. Returns
|
|
186
|
-
* a `SimulatorJsonResponse` error on failure or `null` on success.
|
|
187
|
-
* Only consulted when `strictAuth: true`.
|
|
188
|
-
*
|
|
189
|
-
* @param authToken - The `Authorization` header value from the request.
|
|
190
|
-
* @param endpoint - B2 endpoint name being invoked.
|
|
191
|
-
* @param bucketId - Optional bucket ID the request operates on, for scope check.
|
|
192
|
-
* @param fileName - Optional file name the request operates on, for prefix check.
|
|
193
|
-
*
|
|
194
|
-
* @returns `null` when the request is authorized, otherwise a 401/403 response.
|
|
195
|
-
*/
|
|
196
|
-
authorizeRequest(authToken, endpoint, bucketId2, fileName) {
|
|
197
|
-
const required = missingCapabilitiesFor(endpoint, []);
|
|
198
|
-
const noCheckNeeded = required.length === 0 && endpoint === "b2_authorize_account";
|
|
199
|
-
if (noCheckNeeded) return null;
|
|
200
|
-
if (authToken === void 0 || authToken === "") {
|
|
201
|
-
return this.error(401, "bad_auth_token", "missing Authorization header");
|
|
202
|
-
}
|
|
203
|
-
const token = this.issuedTokens.get(authToken);
|
|
204
|
-
if (!token) {
|
|
205
|
-
return this.error(401, "bad_auth_token", "unknown auth token");
|
|
206
|
-
}
|
|
207
|
-
if (this.now() > token.expiresAt) {
|
|
208
|
-
return this.error(401, "expired_auth_token", "auth token has expired; reauthorize");
|
|
209
|
-
}
|
|
210
|
-
const missing = missingCapabilitiesFor(endpoint, token.capabilities);
|
|
211
|
-
if (missing.length > 0) {
|
|
212
|
-
return this.error(
|
|
213
|
-
403,
|
|
214
|
-
"unauthorized",
|
|
215
|
-
`application key lacks required capabilities: ${missing.join(", ")}`
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
if (token.bucketId !== null && bucketId2 !== void 0 && bucketId2 !== token.bucketId) {
|
|
219
|
-
return this.error(
|
|
220
|
-
403,
|
|
221
|
-
"unauthorized",
|
|
222
|
-
`application key is scoped to bucket ${token.bucketId}; cannot access ${bucketId2}`
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
if (token.namePrefix !== null && fileName !== void 0 && !fileName.startsWith(token.namePrefix)) {
|
|
226
|
-
return this.error(
|
|
227
|
-
403,
|
|
228
|
-
"unauthorized",
|
|
229
|
-
`application key is scoped to prefix "${token.namePrefix}"; "${fileName}" is outside scope`
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Look up the application key matching the `Authorization` header on
|
|
236
|
-
* an `authorize_account` request. The header is in the form
|
|
237
|
-
* `Basic base64(applicationKeyId:applicationKey)`.
|
|
238
|
-
*
|
|
239
|
-
* Returns `null` for the implicit master credential (anything that
|
|
240
|
-
* does not match a key minted via `b2_create_key`); in that case
|
|
241
|
-
* `authorize` grants the full master capability set.
|
|
242
|
-
*
|
|
243
|
-
* @param authzHeader - Raw HTTP `Authorization` header value.
|
|
244
|
-
*
|
|
245
|
-
* @returns The matching key's grant scope, or `null` for the master.
|
|
246
|
-
*/
|
|
247
|
-
findKeyForAuthHeader(authzHeader) {
|
|
248
|
-
if (!authzHeader || !authzHeader.startsWith("Basic ")) return null;
|
|
249
|
-
const decoded = (() => {
|
|
250
|
-
try {
|
|
251
|
-
return atob(authzHeader.slice(6));
|
|
252
|
-
} catch {
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
})();
|
|
256
|
-
if (decoded === null) return null;
|
|
257
|
-
const idx = decoded.indexOf(":");
|
|
258
|
-
if (idx === -1) return null;
|
|
259
|
-
const applicationKeyId = decoded.slice(0, idx);
|
|
260
|
-
const applicationKey = decoded.slice(idx + 1);
|
|
261
|
-
const stored = this.keys.get(applicationKeyId);
|
|
262
|
-
if (!stored || stored.applicationKey !== applicationKey) return null;
|
|
263
|
-
return {
|
|
264
|
-
capabilities: stored.capabilities,
|
|
265
|
-
bucketId: stored.bucketId,
|
|
266
|
-
namePrefix: stored.namePrefix,
|
|
267
|
-
applicationKeyId
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* Creates an {@link HttpTransport} that routes requests to this simulator.
|
|
272
|
-
* @returns A transport instance backed by this in-memory simulator.
|
|
273
|
-
*/
|
|
274
|
-
transport() {
|
|
275
|
-
return new SimulatorTransport(this);
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Register a synthetic failure to inject on requests whose URL contains
|
|
279
|
-
* `spec.on`. Use this to exercise retry / backoff / error-handling
|
|
280
|
-
* paths in tests without hand-rolling a wrapping `HttpTransport`. The
|
|
281
|
-
* fault is consumed in registration order on each matched request;
|
|
282
|
-
* once its `count` budget is exhausted it auto-retires.
|
|
283
|
-
*
|
|
284
|
-
* Faults are checked BEFORE the simulator's real handlers run, so a
|
|
285
|
-
* matched request never touches in-memory state — failed uploads
|
|
286
|
-
* don't create partial parts, failed deletes don't remove anything.
|
|
287
|
-
*
|
|
288
|
-
* @param spec - The failure to inject. See {@link FaultSpec}.
|
|
289
|
-
*
|
|
290
|
-
* @returns A handle whose `clear()` method removes this specific
|
|
291
|
-
* fault registration (other faults remain in effect).
|
|
292
|
-
*
|
|
293
|
-
* @example
|
|
294
|
-
* ```ts
|
|
295
|
-
* // Fail the next 2 b2_upload_part calls with 503, then succeed.
|
|
296
|
-
* sim.injectFailure({ on: 'b2_upload_part', status: 503, count: 2 })
|
|
297
|
-
*
|
|
298
|
-
* // Fail every b2_authorize_account with 401 + Retry-After: 5.
|
|
299
|
-
* const handle = sim.injectFailure({
|
|
300
|
-
* on: 'b2_authorize_account',
|
|
301
|
-
* status: 401,
|
|
302
|
-
* code: 'expired_auth_token',
|
|
303
|
-
* retryAfter: 5,
|
|
304
|
-
* })
|
|
305
|
-
* // ... later
|
|
306
|
-
* handle.clear()
|
|
307
|
-
* ```
|
|
308
|
-
*/
|
|
309
|
-
injectFailure(spec) {
|
|
310
|
-
const id = this.nextFaultId++;
|
|
311
|
-
const fault = {
|
|
312
|
-
id,
|
|
313
|
-
spec,
|
|
314
|
-
remainingSkip: spec.skip ?? 0,
|
|
315
|
-
remainingCount: spec.count ?? Number.POSITIVE_INFINITY
|
|
316
|
-
};
|
|
317
|
-
this.faults.push(fault);
|
|
318
|
-
return {
|
|
319
|
-
clear: () => {
|
|
320
|
-
const idx = this.faults.findIndex((f) => f.id === id);
|
|
321
|
-
if (idx !== -1) this.faults.splice(idx, 1);
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Remove every registered fault. Equivalent to calling `.clear()` on
|
|
327
|
-
* every handle returned by {@link injectFailure}, plus a defensive
|
|
328
|
-
* reset for tests that re-use a simulator across cases.
|
|
329
|
-
*/
|
|
330
|
-
clearFaults() {
|
|
331
|
-
this.faults.length = 0;
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Internal: checks the registered faults for a match on the given URL
|
|
335
|
-
* and consumes one if it should fire. Called from
|
|
336
|
-
* {@link SimulatorTransport.send} before any real handler runs.
|
|
337
|
-
*
|
|
338
|
-
* @param url - The request URL to match against each fault's `on`
|
|
339
|
-
* substring.
|
|
340
|
-
*
|
|
341
|
-
* @returns The fault to apply, or `null` if no fault should fire.
|
|
342
|
-
*
|
|
343
|
-
* @internal
|
|
344
|
-
*/
|
|
345
|
-
consumeMatchingFault(url) {
|
|
346
|
-
for (let i = 0; i < this.faults.length; i++) {
|
|
347
|
-
const fault = this.faults[i];
|
|
348
|
-
if (!url.includes(fault.spec.on)) continue;
|
|
349
|
-
if (fault.remainingSkip > 0) {
|
|
350
|
-
fault.remainingSkip -= 1;
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
if (fault.remainingCount <= 0) continue;
|
|
354
|
-
fault.remainingCount -= 1;
|
|
355
|
-
if (fault.remainingCount <= 0) {
|
|
356
|
-
this.faults.splice(i, 1);
|
|
357
|
-
}
|
|
358
|
-
return fault.spec;
|
|
359
|
-
}
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Dispatches a JSON API request to the appropriate handler.
|
|
364
|
-
* @param _method - The HTTP method (unused).
|
|
365
|
-
* @param path - The request URL path containing the B2 endpoint name.
|
|
366
|
-
* @param headers - The HTTP request headers; consulted by the
|
|
367
|
-
* strict-auth gate to look up the issued auth token.
|
|
368
|
-
* @param body - The parsed JSON request body.
|
|
369
|
-
*
|
|
370
|
-
* @returns An object with HTTP status and JSON response body.
|
|
371
|
-
*/
|
|
372
|
-
async handleRequest(_method, path, headers, body) {
|
|
373
|
-
const endpoint = path.split("/").pop() ?? "";
|
|
374
|
-
if (this.strictAuth) {
|
|
375
|
-
const authError = this.authorizeRequest(headers["authorization"], endpoint);
|
|
376
|
-
if (authError !== null) return authError;
|
|
377
|
-
}
|
|
378
|
-
switch (endpoint) {
|
|
379
|
-
case "b2_authorize_account":
|
|
380
|
-
return this.authorize(headers["authorization"]);
|
|
381
|
-
case "b2_create_bucket":
|
|
382
|
-
return this.createBucket(
|
|
383
|
-
body
|
|
384
|
-
);
|
|
385
|
-
case "b2_list_buckets":
|
|
386
|
-
return this.listBuckets();
|
|
387
|
-
case "b2_delete_bucket":
|
|
388
|
-
return this.deleteBucket(body);
|
|
389
|
-
case "b2_update_bucket":
|
|
390
|
-
return this.updateBucket(body);
|
|
391
|
-
case "b2_get_upload_url":
|
|
392
|
-
return this.getUploadUrl(body);
|
|
393
|
-
case "b2_list_file_names":
|
|
394
|
-
return this.listFileNames(
|
|
395
|
-
body
|
|
396
|
-
);
|
|
397
|
-
case "b2_list_file_versions":
|
|
398
|
-
return this.listFileVersions(
|
|
399
|
-
body
|
|
400
|
-
);
|
|
401
|
-
case "b2_get_file_info":
|
|
402
|
-
return this.getFileInfo(body);
|
|
403
|
-
case "b2_hide_file":
|
|
404
|
-
return this.hideFile(body);
|
|
405
|
-
case "b2_delete_file_version":
|
|
406
|
-
return this.deleteFileVersion(
|
|
407
|
-
body
|
|
408
|
-
);
|
|
409
|
-
case "b2_copy_file":
|
|
410
|
-
return this.copyFile(
|
|
411
|
-
body
|
|
412
|
-
);
|
|
413
|
-
case "b2_start_large_file":
|
|
414
|
-
return this.startLargeFile(
|
|
415
|
-
body
|
|
416
|
-
);
|
|
417
|
-
case "b2_get_upload_part_url":
|
|
418
|
-
return this.getUploadPartUrl(body);
|
|
419
|
-
case "b2_finish_large_file":
|
|
420
|
-
return this.finishLargeFile(body);
|
|
421
|
-
case "b2_cancel_large_file":
|
|
422
|
-
return this.cancelLargeFile(body);
|
|
423
|
-
case "b2_list_unfinished_large_files":
|
|
424
|
-
return this.listUnfinishedLargeFiles(
|
|
425
|
-
body
|
|
426
|
-
);
|
|
427
|
-
case "b2_list_parts":
|
|
428
|
-
return this.listParts(
|
|
429
|
-
body
|
|
430
|
-
);
|
|
431
|
-
case "b2_copy_part":
|
|
432
|
-
return await this.copyPart(
|
|
433
|
-
body
|
|
434
|
-
);
|
|
435
|
-
case "b2_get_download_authorization":
|
|
436
|
-
return this.getDownloadAuthorization(
|
|
437
|
-
body
|
|
438
|
-
);
|
|
439
|
-
case "b2_create_key":
|
|
440
|
-
return this.createKey(
|
|
441
|
-
body
|
|
442
|
-
);
|
|
443
|
-
case "b2_list_keys":
|
|
444
|
-
return this.listKeys(
|
|
445
|
-
body
|
|
446
|
-
);
|
|
447
|
-
case "b2_delete_key":
|
|
448
|
-
return this.deleteKey(body);
|
|
449
|
-
case "b2_update_file_retention":
|
|
450
|
-
return this.updateFileRetention(
|
|
451
|
-
body
|
|
452
|
-
);
|
|
453
|
-
case "b2_update_file_legal_hold":
|
|
454
|
-
return this.updateFileLegalHold(
|
|
455
|
-
body
|
|
456
|
-
);
|
|
457
|
-
case "b2_get_bucket_notification_rules":
|
|
458
|
-
return this.getBucketNotificationRules(body);
|
|
459
|
-
case "b2_set_bucket_notification_rules":
|
|
460
|
-
return this.setBucketNotificationRules(
|
|
461
|
-
body
|
|
462
|
-
);
|
|
463
|
-
default:
|
|
464
|
-
return {
|
|
465
|
-
status: 400,
|
|
466
|
-
body: { status: 400, code: "bad_request", message: `Unknown endpoint: ${endpoint}` }
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Handles file and part upload requests (`b2_upload_file`, `b2_upload_part`).
|
|
472
|
-
* Dispatches to the appropriate internal handler based on the URL.
|
|
473
|
-
* @param url - The upload endpoint URL used to determine the upload type.
|
|
474
|
-
* @param headers - The HTTP headers containing file metadata and authorization.
|
|
475
|
-
* @param data - The raw file or part content as bytes.
|
|
476
|
-
*
|
|
477
|
-
* @returns An object with HTTP status and JSON response body.
|
|
478
|
-
*/
|
|
479
|
-
handleUpload(url, headers, data) {
|
|
480
|
-
const isPart = url.includes("b2_upload_part");
|
|
481
|
-
if (this.strictAuth) {
|
|
482
|
-
const endpoint = isPart ? "b2_upload_part" : "b2_upload_file";
|
|
483
|
-
const authError = this.authorizeRequest(headers["authorization"], endpoint);
|
|
484
|
-
if (authError !== null) return authError;
|
|
485
|
-
}
|
|
486
|
-
if (isPart) {
|
|
487
|
-
return this.handleUploadPart(url, headers, data);
|
|
488
|
-
}
|
|
489
|
-
return this.handleUploadFile(url, headers, data);
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* Handles file download requests (`b2_download_file_by_id`, `/file/` by name).
|
|
493
|
-
* Returns the file data along with B2 response headers.
|
|
494
|
-
* @param path - The request URL path identifying the file to download.
|
|
495
|
-
* @param headers - The HTTP request headers for range or authorization.
|
|
496
|
-
* @param method - The HTTP method; `'HEAD'` suppresses the response body.
|
|
497
|
-
*
|
|
498
|
-
* @returns The download response containing file data and B2 headers.
|
|
499
|
-
*/
|
|
500
|
-
handleDownload(path, headers, method = "GET") {
|
|
501
|
-
if (path.includes("b2_download_file_by_id")) {
|
|
502
|
-
if (this.strictAuth) {
|
|
503
|
-
const authError = this.authorizeRequest(headers["authorization"], "b2_download_file_by_id");
|
|
504
|
-
if (authError !== null) return this.errorAsDownload(authError);
|
|
505
|
-
}
|
|
506
|
-
const url = new URL(`http://localhost${path}`);
|
|
507
|
-
const fileId2 = url.searchParams.get("fileId") ?? "";
|
|
508
|
-
return this.finalizeDownload(this.downloadById(fileId2, headers["range"]), url, method);
|
|
509
|
-
}
|
|
510
|
-
const fileMatch = path.match(/^([^?]+)/)?.[1]?.match(/\/file\/([^/]+)\/(.+)/);
|
|
511
|
-
if (fileMatch) {
|
|
512
|
-
const bucketName = decodeURIComponent(fileMatch[1] ?? "");
|
|
513
|
-
const fileName = decodeURIComponent(fileMatch[2] ?? "");
|
|
514
|
-
if (this.strictAuth) {
|
|
515
|
-
const authError = this.authorizeRequest(
|
|
516
|
-
headers["authorization"],
|
|
517
|
-
"b2_download_file_by_name",
|
|
518
|
-
void 0,
|
|
519
|
-
fileName
|
|
520
|
-
);
|
|
521
|
-
if (authError !== null) return this.errorAsDownload(authError);
|
|
522
|
-
}
|
|
523
|
-
const url = new URL(`http://localhost${path}`);
|
|
524
|
-
return this.finalizeDownload(
|
|
525
|
-
this.downloadByName(bucketName, fileName, headers["range"]),
|
|
526
|
-
url,
|
|
527
|
-
method
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
return { status: 404, headers: {}, data: null };
|
|
531
|
-
}
|
|
532
|
-
/**
|
|
533
|
-
* Convert a synthetic JSON error body into a `SimulatorDownloadResponse`
|
|
534
|
-
* shape so the strict-auth gate on download paths can surface 401/403s
|
|
535
|
-
* through the same transport plumbing as a regular failed download.
|
|
536
|
-
*
|
|
537
|
-
* @param json - The JSON error from {@link authorizeRequest}.
|
|
538
|
-
*
|
|
539
|
-
* @returns A download-shaped response with the error body bytes inline.
|
|
540
|
-
*/
|
|
541
|
-
errorAsDownload(json) {
|
|
542
|
-
return {
|
|
543
|
-
status: json.status,
|
|
544
|
-
headers: { "Content-Type": "application/json" },
|
|
545
|
-
data: utf8Encoder.encode(JSON.stringify(json.body))
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* Applies HEAD-method body suppression and `b2Content*` response-header
|
|
550
|
-
* overrides parsed from the download URL's query string. Mirrors what the
|
|
551
|
-
* real B2 service does: any `b2Content*` query parameter is echoed back as
|
|
552
|
-
* the corresponding response header.
|
|
553
|
-
*
|
|
554
|
-
* @param response - The download response produced by {@link downloadById} or {@link downloadByName}.
|
|
555
|
-
* @param url - The parsed download URL (used to read `b2Content*` query params).
|
|
556
|
-
* @param method - The HTTP method of the originating request.
|
|
557
|
-
*
|
|
558
|
-
* @returns The response with overrides applied.
|
|
559
|
-
*/
|
|
560
|
-
finalizeDownload(response, url, method) {
|
|
561
|
-
const overrideMap = {
|
|
562
|
-
b2ContentDisposition: "Content-Disposition",
|
|
563
|
-
b2ContentLanguage: "Content-Language",
|
|
564
|
-
b2ContentEncoding: "Content-Encoding",
|
|
565
|
-
b2ContentType: "Content-Type",
|
|
566
|
-
b2CacheControl: "Cache-Control",
|
|
567
|
-
b2Expires: "Expires"
|
|
568
|
-
};
|
|
569
|
-
const newHeaders = { ...response.headers };
|
|
570
|
-
for (const [param, header] of Object.entries(overrideMap)) {
|
|
571
|
-
const value = url.searchParams.get(param);
|
|
572
|
-
if (value !== null) newHeaders[header] = value;
|
|
573
|
-
}
|
|
574
|
-
const data = method === "HEAD" ? null : response.data;
|
|
575
|
-
return { status: response.status, headers: newHeaders, data };
|
|
576
|
-
}
|
|
577
|
-
handleUploadFile(url, headers, data) {
|
|
578
|
-
const bucketId2 = new URL(url).searchParams.get("bucketId");
|
|
579
|
-
if (!bucketId2) return this.error(400, "bad_request", "Missing bucketId");
|
|
580
|
-
const bucket = this.buckets.get(bucketId2);
|
|
581
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
582
|
-
const fileName = decodeURIComponent(headers["x-bz-file-name"] ?? "");
|
|
583
|
-
const contentType = headers["content-type"] ?? "application/octet-stream";
|
|
584
|
-
const sha1 = headers["x-bz-content-sha1"] ?? "none";
|
|
585
|
-
const fileNameError = validateFileName(fileName);
|
|
586
|
-
if (fileNameError) return this.error(400, fileNameError.code, fileNameError.message);
|
|
587
|
-
const fileInfo = parseFileInfoHeaders(headers);
|
|
588
|
-
const fileInfoError = validateFileInfo(fileInfo);
|
|
589
|
-
if (fileInfoError) return this.error(400, fileInfoError.code, fileInfoError.message);
|
|
590
|
-
const fileVersion = this.makeFileVersion({
|
|
591
|
-
bucketId: bucketId2,
|
|
592
|
-
fileName,
|
|
593
|
-
contentType,
|
|
594
|
-
contentLength: data.byteLength,
|
|
595
|
-
contentSha1: sha1,
|
|
596
|
-
action: FileAction.Upload
|
|
597
|
-
});
|
|
598
|
-
const stored = { fileVersion, data };
|
|
599
|
-
const existing = bucket.files.get(fileName);
|
|
600
|
-
if (existing) {
|
|
601
|
-
existing.push(stored);
|
|
602
|
-
} else {
|
|
603
|
-
bucket.files.set(fileName, [stored]);
|
|
604
|
-
}
|
|
605
|
-
this.firePostUploadHooks(fileVersion, bucketId2, "b2:ObjectCreated:Upload");
|
|
606
|
-
return { status: 200, body: fileVersion };
|
|
607
|
-
}
|
|
608
|
-
handleUploadPart(url, headers, data) {
|
|
609
|
-
const fileId2 = new URL(url).searchParams.get("fileId");
|
|
610
|
-
if (!fileId2) return this.error(400, "bad_request", "Missing fileId");
|
|
611
|
-
const large = this.largeFiles.get(fileId2);
|
|
612
|
-
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
613
|
-
const partNumber = Number.parseInt(headers["x-bz-part-number"] ?? "0", 10);
|
|
614
|
-
const sha1 = headers["x-bz-content-sha1"] ?? "none";
|
|
615
|
-
large.parts.set(partNumber, { data, sha1 });
|
|
616
|
-
return {
|
|
617
|
-
status: 200,
|
|
618
|
-
body: {
|
|
619
|
-
fileId: large.fileId,
|
|
620
|
-
partNumber,
|
|
621
|
-
contentLength: data.byteLength,
|
|
622
|
-
contentSha1: sha1,
|
|
623
|
-
serverSideEncryption: { mode: EncryptionMode.None },
|
|
624
|
-
uploadTimestamp: Date.now()
|
|
625
|
-
}
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
downloadById(fileId2, range) {
|
|
629
|
-
const found = this.findFile(fileId2);
|
|
630
|
-
if (found === null) return { status: 404, headers: {}, data: null };
|
|
631
|
-
return this.serveFile(found.stored, range);
|
|
632
|
-
}
|
|
633
|
-
downloadByName(bucketName, fileName, range) {
|
|
634
|
-
for (const bucket of this.buckets.values()) {
|
|
635
|
-
if (bucket.info.bucketName !== bucketName) continue;
|
|
636
|
-
const versions = bucket.files.get(fileName);
|
|
637
|
-
if (!versions || versions.length === 0) break;
|
|
638
|
-
const latest = versions[versions.length - 1];
|
|
639
|
-
if (!latest || latest.fileVersion.action === FileAction.Hide) {
|
|
640
|
-
return { status: 404, headers: {}, data: null };
|
|
641
|
-
}
|
|
642
|
-
return this.serveFile(latest, range);
|
|
643
|
-
}
|
|
644
|
-
return { status: 404, headers: {}, data: null };
|
|
645
|
-
}
|
|
646
|
-
serveFile(stored, range) {
|
|
647
|
-
const fullData = stored.data;
|
|
648
|
-
let data = fullData;
|
|
649
|
-
let status = 200;
|
|
650
|
-
let contentRange = null;
|
|
651
|
-
if (range) {
|
|
652
|
-
const parsed = parseRangeHeader(range, fullData.byteLength);
|
|
653
|
-
if (parsed.kind === "ok") {
|
|
654
|
-
data = fullData.subarray(parsed.start, parsed.end + 1);
|
|
655
|
-
status = 206;
|
|
656
|
-
contentRange = `bytes ${parsed.start}-${parsed.end}/${fullData.byteLength}`;
|
|
657
|
-
} else if (parsed.kind === "unsatisfiable") {
|
|
658
|
-
return {
|
|
659
|
-
status: 416,
|
|
660
|
-
headers: {
|
|
661
|
-
"Content-Type": stored.fileVersion.contentType,
|
|
662
|
-
"Content-Range": `bytes */${fullData.byteLength}`
|
|
663
|
-
},
|
|
664
|
-
data: new Uint8Array(0)
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
const fv = stored.fileVersion;
|
|
669
|
-
const headers = {
|
|
670
|
-
"Content-Type": fv.contentType,
|
|
671
|
-
"Content-Length": String(data.byteLength),
|
|
672
|
-
"X-Bz-File-Id": fv.fileId,
|
|
673
|
-
"X-Bz-File-Name": encodeURIComponent(fv.fileName),
|
|
674
|
-
"X-Bz-Content-Sha1": fv.contentSha1 ?? "none",
|
|
675
|
-
"X-Bz-Upload-Timestamp": String(fv.uploadTimestamp),
|
|
676
|
-
"X-Bz-Info-src_last_modified_millis": String(fv.uploadTimestamp)
|
|
677
|
-
};
|
|
678
|
-
if (contentRange !== null) {
|
|
679
|
-
headers["Content-Range"] = contentRange;
|
|
680
|
-
}
|
|
681
|
-
return { status, headers, data };
|
|
682
|
-
}
|
|
683
|
-
// --- API handlers ---
|
|
684
|
-
authorize(authzHeader) {
|
|
685
|
-
const capabilities = [
|
|
686
|
-
Capability.ListBuckets,
|
|
687
|
-
Capability.ReadBuckets,
|
|
688
|
-
Capability.WriteBuckets,
|
|
689
|
-
Capability.DeleteBuckets,
|
|
690
|
-
Capability.ListFiles,
|
|
691
|
-
Capability.ReadFiles,
|
|
692
|
-
Capability.WriteFiles,
|
|
693
|
-
Capability.DeleteFiles,
|
|
694
|
-
Capability.ListKeys,
|
|
695
|
-
Capability.WriteKeys,
|
|
696
|
-
Capability.DeleteKeys,
|
|
697
|
-
Capability.ShareFiles,
|
|
698
|
-
Capability.ReadBucketNotifications,
|
|
699
|
-
Capability.WriteBucketNotifications
|
|
700
|
-
];
|
|
701
|
-
const keyForAuth = this.findKeyForAuthHeader(authzHeader);
|
|
702
|
-
const tokenStr = `sim_auth_token_${this.nextId++}`;
|
|
703
|
-
this.issuedTokens.set(tokenStr, {
|
|
704
|
-
capabilities: keyForAuth?.capabilities ?? capabilities,
|
|
705
|
-
bucketId: keyForAuth?.bucketId ?? null,
|
|
706
|
-
namePrefix: keyForAuth?.namePrefix ?? null,
|
|
707
|
-
expiresAt: this.now() + this.authTokenTtlMs,
|
|
708
|
-
applicationKeyId: keyForAuth?.applicationKeyId ?? null
|
|
709
|
-
});
|
|
710
|
-
return {
|
|
711
|
-
status: 200,
|
|
712
|
-
body: {
|
|
713
|
-
accountId: accountId(this.accountId),
|
|
714
|
-
// `AuthToken` has no public factory by design — auth tokens are
|
|
715
|
-
// minted by B2, not constructed by user code. The simulator is
|
|
716
|
-
// the only legitimate place that needs to forge one.
|
|
717
|
-
authorizationToken: tokenStr,
|
|
718
|
-
apiInfo: {
|
|
719
|
-
storageApi: {
|
|
720
|
-
absoluteMinimumPartSize: this.minimumPartSize,
|
|
721
|
-
apiUrl: "http://localhost:0",
|
|
722
|
-
bucketId: keyForAuth?.bucketId ?? null,
|
|
723
|
-
bucketName: null,
|
|
724
|
-
downloadUrl: "http://localhost:0",
|
|
725
|
-
infoType: "storageApi",
|
|
726
|
-
namePrefix: keyForAuth?.namePrefix ?? null,
|
|
727
|
-
recommendedPartSize: this.recommendedPartSize,
|
|
728
|
-
s3ApiUrl: "http://localhost:0",
|
|
729
|
-
allowed: {
|
|
730
|
-
capabilities: keyForAuth?.capabilities ?? capabilities,
|
|
731
|
-
bucketId: keyForAuth?.bucketId ?? null,
|
|
732
|
-
bucketName: null,
|
|
733
|
-
namePrefix: keyForAuth?.namePrefix ?? null
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
},
|
|
737
|
-
applicationKeyExpirationTimestamp: null
|
|
738
|
-
}
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
createBucket(req) {
|
|
742
|
-
const nameError = validateBucketName(req.bucketName);
|
|
743
|
-
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
744
|
-
if (req.bucketInfo !== void 0) {
|
|
745
|
-
const infoError = validateBucketInfo(req.bucketInfo);
|
|
746
|
-
if (infoError) return this.error(400, infoError.code, infoError.message);
|
|
747
|
-
}
|
|
748
|
-
for (const b of this.buckets.values()) {
|
|
749
|
-
if (b.info.bucketName === req.bucketName) {
|
|
750
|
-
return this.error(400, "duplicate_bucket_name", "Bucket name already in use");
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
const bid = bucketId(this.genId("b2_bucket"));
|
|
754
|
-
const defaultRetention = req.defaultRetention ?? {
|
|
755
|
-
mode: BucketRetentionMode.None,
|
|
756
|
-
period: null
|
|
757
|
-
};
|
|
758
|
-
const info = {
|
|
759
|
-
accountId: accountId(req.accountId),
|
|
760
|
-
bucketId: bid,
|
|
761
|
-
bucketName: req.bucketName,
|
|
762
|
-
bucketType: req.bucketType,
|
|
763
|
-
bucketInfo: req.bucketInfo ?? {},
|
|
764
|
-
corsRules: req.corsRules ?? [],
|
|
765
|
-
defaultServerSideEncryption: req.defaultServerSideEncryption ?? { mode: EncryptionMode.None },
|
|
766
|
-
fileLockConfiguration: {
|
|
767
|
-
isClientAuthorizedToRead: true,
|
|
768
|
-
value: {
|
|
769
|
-
isFileLockEnabled: req.fileLockEnabled ?? false,
|
|
770
|
-
defaultRetention
|
|
771
|
-
}
|
|
772
|
-
},
|
|
773
|
-
lifecycleRules: req.lifecycleRules ?? [],
|
|
774
|
-
options: [],
|
|
775
|
-
revision: 1,
|
|
776
|
-
defaultRetention,
|
|
777
|
-
replicationConfiguration: req.replicationConfiguration ?? {
|
|
778
|
-
asReplicationSource: null,
|
|
779
|
-
asReplicationDestination: null
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
this.buckets.set(bid, { info, files: /* @__PURE__ */ new Map() });
|
|
783
|
-
return { status: 200, body: info };
|
|
784
|
-
}
|
|
785
|
-
listBuckets() {
|
|
786
|
-
const buckets = [...this.buckets.values()].map((b) => b.info);
|
|
787
|
-
return { status: 200, body: { buckets } };
|
|
788
|
-
}
|
|
789
|
-
deleteBucket(req) {
|
|
790
|
-
const bucket = this.buckets.get(req.bucketId);
|
|
791
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
792
|
-
this.buckets.delete(req.bucketId);
|
|
793
|
-
return { status: 200, body: bucket.info };
|
|
794
|
-
}
|
|
795
|
-
updateBucket(req) {
|
|
796
|
-
const bucket = this.buckets.get(req["bucketId"]);
|
|
797
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
798
|
-
if (req["bucketInfo"] !== void 0) {
|
|
799
|
-
const infoError = validateBucketInfo(req["bucketInfo"]);
|
|
800
|
-
if (infoError) return this.error(400, infoError.code, infoError.message);
|
|
801
|
-
}
|
|
802
|
-
const updated = {
|
|
803
|
-
...bucket.info,
|
|
804
|
-
...req["bucketType"] !== void 0 ? { bucketType: req["bucketType"] } : {},
|
|
805
|
-
...req["bucketInfo"] !== void 0 ? { bucketInfo: req["bucketInfo"] } : {},
|
|
806
|
-
...req["lifecycleRules"] !== void 0 ? { lifecycleRules: req["lifecycleRules"] } : {},
|
|
807
|
-
...req["corsRules"] !== void 0 ? { corsRules: req["corsRules"] } : {},
|
|
808
|
-
...req["replicationConfiguration"] !== void 0 ? {
|
|
809
|
-
replicationConfiguration: req["replicationConfiguration"]
|
|
810
|
-
} : {},
|
|
811
|
-
...req["defaultRetention"] !== void 0 ? {
|
|
812
|
-
defaultRetention: req["defaultRetention"],
|
|
813
|
-
fileLockConfiguration: {
|
|
814
|
-
isClientAuthorizedToRead: true,
|
|
815
|
-
value: {
|
|
816
|
-
isFileLockEnabled: bucket.info.fileLockConfiguration.value?.isFileLockEnabled ?? false,
|
|
817
|
-
defaultRetention: req["defaultRetention"]
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
} : {},
|
|
821
|
-
...req["defaultServerSideEncryption"] !== void 0 ? {
|
|
822
|
-
defaultServerSideEncryption: req["defaultServerSideEncryption"]
|
|
823
|
-
} : {},
|
|
824
|
-
revision: bucket.info.revision + 1
|
|
825
|
-
};
|
|
826
|
-
this.buckets.set(req["bucketId"], { info: updated, files: bucket.files });
|
|
827
|
-
return { status: 200, body: updated };
|
|
828
|
-
}
|
|
829
|
-
getUploadUrl(req) {
|
|
830
|
-
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
831
|
-
return {
|
|
832
|
-
status: 200,
|
|
833
|
-
body: {
|
|
834
|
-
bucketId: req.bucketId,
|
|
835
|
-
uploadUrl: `http://localhost:0/b2api/v3/b2_upload_file?bucketId=${req.bucketId}`,
|
|
836
|
-
authorizationToken: "sim_upload_token"
|
|
837
|
-
}
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
listFileNames(req) {
|
|
841
|
-
const bucket = this.buckets.get(req.bucketId);
|
|
842
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
843
|
-
const countError = validateMaxCount(req.maxFileCount, "b2_list_file_names");
|
|
844
|
-
if (countError) return this.error(400, countError.code, countError.message);
|
|
845
|
-
const max = req.maxFileCount ?? 1e3;
|
|
846
|
-
const prefix = req.prefix ?? "";
|
|
847
|
-
let allFiles = [...bucket.files.entries()].filter(([name]) => name.startsWith(prefix)).map(([_, versions]) => versions[versions.length - 1]).filter((v) => v !== void 0).map((v) => v.fileVersion).sort((a, b) => a.fileName.localeCompare(b.fileName));
|
|
848
|
-
if (req.startFileName) {
|
|
849
|
-
const start = req.startFileName;
|
|
850
|
-
allFiles = allFiles.filter((f) => f.fileName >= start);
|
|
851
|
-
}
|
|
852
|
-
const files = allFiles.slice(0, max);
|
|
853
|
-
const nextFileName = allFiles.length > max ? allFiles[max]?.fileName ?? null : null;
|
|
854
|
-
return { status: 200, body: { files, nextFileName } };
|
|
855
|
-
}
|
|
856
|
-
listFileVersions(req) {
|
|
857
|
-
const bucket = this.buckets.get(req.bucketId);
|
|
858
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
859
|
-
const countError = validateMaxCount(req.maxFileCount, "b2_list_file_versions");
|
|
860
|
-
if (countError) return this.error(400, countError.code, countError.message);
|
|
861
|
-
const max = req.maxFileCount ?? 1e3;
|
|
862
|
-
const prefix = req.prefix ?? "";
|
|
863
|
-
const allVersions = [...bucket.files.entries()].filter(([name]) => name.startsWith(prefix)).flatMap(([_, versions]) => versions.map((v) => v.fileVersion)).sort((a, b) => {
|
|
864
|
-
const nameCmp = a.fileName.localeCompare(b.fileName);
|
|
865
|
-
if (nameCmp !== 0) return nameCmp;
|
|
866
|
-
return b.uploadTimestamp - a.uploadTimestamp;
|
|
867
|
-
});
|
|
868
|
-
let startIdx = 0;
|
|
869
|
-
if (req.startFileName !== void 0) {
|
|
870
|
-
const startName = req.startFileName;
|
|
871
|
-
const startId = req.startFileId;
|
|
872
|
-
const nameIdx = allVersions.findIndex((f) => f.fileName >= startName);
|
|
873
|
-
if (nameIdx === -1) {
|
|
874
|
-
startIdx = allVersions.length;
|
|
875
|
-
} else if (startId !== void 0) {
|
|
876
|
-
const exactIdx = allVersions.findIndex((f, i) => i >= nameIdx && f.fileId === startId);
|
|
877
|
-
startIdx = exactIdx !== -1 ? exactIdx : nameIdx;
|
|
878
|
-
} else {
|
|
879
|
-
startIdx = nameIdx;
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
const sliced = allVersions.slice(startIdx, startIdx + max);
|
|
883
|
-
const hasMore = startIdx + max < allVersions.length;
|
|
884
|
-
const nextFileName = hasMore ? allVersions[startIdx + max]?.fileName ?? null : null;
|
|
885
|
-
const nextFileId = hasMore ? allVersions[startIdx + max]?.fileId ?? null : null;
|
|
886
|
-
return { status: 200, body: { files: sliced, nextFileName, nextFileId } };
|
|
887
|
-
}
|
|
888
|
-
getFileInfo(req) {
|
|
889
|
-
const found = this.findFile(req.fileId);
|
|
890
|
-
if (found === null) return this.error(404, "file_not_present", "File not found");
|
|
891
|
-
return { status: 200, body: found.stored.fileVersion };
|
|
892
|
-
}
|
|
893
|
-
hideFile(req) {
|
|
894
|
-
const bucket = this.buckets.get(req.bucketId);
|
|
895
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
896
|
-
const nameError = validateFileName(req.fileName);
|
|
897
|
-
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
898
|
-
const fileVersion = this.makeFileVersion({
|
|
899
|
-
bucketId: req.bucketId,
|
|
900
|
-
fileName: req.fileName,
|
|
901
|
-
contentType: "application/octet-stream",
|
|
902
|
-
contentLength: 0,
|
|
903
|
-
contentSha1: "none",
|
|
904
|
-
action: FileAction.Hide
|
|
905
|
-
});
|
|
906
|
-
const existing = bucket.files.get(req.fileName);
|
|
907
|
-
const stored = { fileVersion, data: new Uint8Array(0) };
|
|
908
|
-
if (existing) {
|
|
909
|
-
existing.push(stored);
|
|
910
|
-
} else {
|
|
911
|
-
bucket.files.set(req.fileName, [stored]);
|
|
912
|
-
}
|
|
913
|
-
return { status: 200, body: fileVersion };
|
|
914
|
-
}
|
|
915
|
-
deleteFileVersion(req) {
|
|
916
|
-
const found = this.findFile(req.fileId);
|
|
917
|
-
if (found === null || found.stored.fileVersion.fileName !== req.fileName) {
|
|
918
|
-
return this.error(400, "file_not_present", "File version not found");
|
|
919
|
-
}
|
|
920
|
-
const fv = found.stored.fileVersion;
|
|
921
|
-
const retention = fv.fileRetention?.value;
|
|
922
|
-
const legalHold = fv.legalHold?.value;
|
|
923
|
-
const now = Date.now();
|
|
924
|
-
if (legalHold === "on") {
|
|
925
|
-
return this.error(
|
|
926
|
-
400,
|
|
927
|
-
"file_lock_legal_hold_protected",
|
|
928
|
-
"File is on legal hold and cannot be deleted"
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
if (retention?.mode === "compliance" && retention.retainUntilTimestamp !== null && retention.retainUntilTimestamp > now) {
|
|
932
|
-
return this.error(
|
|
933
|
-
400,
|
|
934
|
-
"file_lock_compliance_protected",
|
|
935
|
-
`File is under compliance-mode retention and cannot be deleted until ${new Date(retention.retainUntilTimestamp).toISOString()}`
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
if (retention?.mode === "governance" && retention.retainUntilTimestamp !== null && retention.retainUntilTimestamp > now && req.bypassGovernance !== true) {
|
|
939
|
-
return this.error(
|
|
940
|
-
400,
|
|
941
|
-
"file_lock_governance_protected",
|
|
942
|
-
"File is under governance-mode retention; pass bypassGovernance: true to delete"
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
found.versions.splice(found.index, 1);
|
|
946
|
-
if (found.versions.length === 0) found.bucket.files.delete(req.fileName);
|
|
947
|
-
return { status: 200, body: { fileId: req.fileId, fileName: req.fileName } };
|
|
948
|
-
}
|
|
949
|
-
copyFile(req) {
|
|
950
|
-
const nameError = validateFileName(req.fileName);
|
|
951
|
-
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
952
|
-
const found = this.findFile(req.sourceFileId);
|
|
953
|
-
if (found === null) return this.error(404, "file_not_present", "Source file not found");
|
|
954
|
-
const sourceStored = found.stored;
|
|
955
|
-
const destBucketId = req.destinationBucketId ?? found.bucketId;
|
|
956
|
-
const destBucket = this.buckets.get(destBucketId);
|
|
957
|
-
if (!destBucket) return this.error(400, "bad_bucket_id", "Destination bucket not found");
|
|
958
|
-
const fileVersion = this.makeFileVersion({
|
|
959
|
-
bucketId: destBucketId,
|
|
960
|
-
fileName: req.fileName,
|
|
961
|
-
contentType: sourceStored.fileVersion.contentType,
|
|
962
|
-
contentLength: sourceStored.data.byteLength,
|
|
963
|
-
contentSha1: sourceStored.fileVersion.contentSha1 ?? "none",
|
|
964
|
-
action: FileAction.Copy
|
|
965
|
-
});
|
|
966
|
-
const copied = { fileVersion, data: new Uint8Array(sourceStored.data) };
|
|
967
|
-
const existing = destBucket.files.get(req.fileName);
|
|
968
|
-
if (existing) {
|
|
969
|
-
existing.push(copied);
|
|
970
|
-
} else {
|
|
971
|
-
destBucket.files.set(req.fileName, [copied]);
|
|
972
|
-
}
|
|
973
|
-
this.firePostUploadHooks(fileVersion, destBucketId, "b2:ObjectCreated:Copy");
|
|
974
|
-
return { status: 200, body: fileVersion };
|
|
975
|
-
}
|
|
976
|
-
startLargeFile(req) {
|
|
977
|
-
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
978
|
-
const nameError = validateFileName(req.fileName);
|
|
979
|
-
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
980
|
-
if (req.fileInfo !== void 0) {
|
|
981
|
-
const infoError = validateFileInfo(req.fileInfo);
|
|
982
|
-
if (infoError) return this.error(400, infoError.code, infoError.message);
|
|
983
|
-
}
|
|
984
|
-
const fid = this.genId("4_z");
|
|
985
|
-
const large = {
|
|
986
|
-
fileId: fid,
|
|
987
|
-
bucketId: req.bucketId,
|
|
988
|
-
fileName: req.fileName,
|
|
989
|
-
contentType: req.contentType,
|
|
990
|
-
fileInfo: req.fileInfo ?? {},
|
|
991
|
-
parts: /* @__PURE__ */ new Map()
|
|
992
|
-
};
|
|
993
|
-
this.largeFiles.set(fid, large);
|
|
994
|
-
return {
|
|
995
|
-
status: 200,
|
|
996
|
-
body: {
|
|
997
|
-
fileId: fid,
|
|
998
|
-
fileName: req.fileName,
|
|
999
|
-
accountId: this.accountId,
|
|
1000
|
-
bucketId: req.bucketId,
|
|
1001
|
-
contentType: req.contentType,
|
|
1002
|
-
fileInfo: large.fileInfo
|
|
1003
|
-
}
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
1006
|
-
getUploadPartUrl(req) {
|
|
1007
|
-
if (!this.largeFiles.has(req.fileId))
|
|
1008
|
-
return this.error(400, "bad_request", "Large file not found");
|
|
1009
|
-
return {
|
|
1010
|
-
status: 200,
|
|
1011
|
-
body: {
|
|
1012
|
-
fileId: req.fileId,
|
|
1013
|
-
uploadUrl: `http://localhost:0/b2api/v3/b2_upload_part?fileId=${req.fileId}`,
|
|
1014
|
-
authorizationToken: "sim_part_token"
|
|
1015
|
-
}
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
finishLargeFile(req) {
|
|
1019
|
-
const large = this.largeFiles.get(req.fileId);
|
|
1020
|
-
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
1021
|
-
const bucket = this.buckets.get(large.bucketId);
|
|
1022
|
-
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
1023
|
-
const sortedParts = [...large.parts.entries()].sort((a, b) => a[0] - b[0]);
|
|
1024
|
-
if (sortedParts.length > 1e4) {
|
|
1025
|
-
return this.error(
|
|
1026
|
-
400,
|
|
1027
|
-
"bad_request",
|
|
1028
|
-
`multipart upload has ${sortedParts.length} parts; B2 caps at 10000`
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1031
|
-
for (const [partNumber] of sortedParts) {
|
|
1032
|
-
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > 1e4) {
|
|
1033
|
-
return this.error(
|
|
1034
|
-
400,
|
|
1035
|
-
"bad_request",
|
|
1036
|
-
`partNumber ${partNumber} is outside the [1, 10000] range B2 accepts`
|
|
1037
|
-
);
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
if (req.partSha1Array.length !== sortedParts.length) {
|
|
1041
|
-
return this.error(
|
|
1042
|
-
400,
|
|
1043
|
-
"bad_request",
|
|
1044
|
-
`partSha1Array has ${req.partSha1Array.length} entries but ${sortedParts.length} parts were uploaded`
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
for (let i = 0; i < sortedParts.length - 1; i++) {
|
|
1048
|
-
const [partNumber, part] = sortedParts[i];
|
|
1049
|
-
if (part.data.byteLength < this.minimumPartSize) {
|
|
1050
|
-
return this.error(
|
|
1051
|
-
400,
|
|
1052
|
-
"bad_request",
|
|
1053
|
-
`part ${partNumber} (${part.data.byteLength} bytes) is smaller than the minimum part size of ${this.minimumPartSize}`
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
let totalSize = 0;
|
|
1058
|
-
for (const [_, part] of sortedParts) totalSize += part.data.byteLength;
|
|
1059
|
-
const combined = new Uint8Array(totalSize);
|
|
1060
|
-
let offset = 0;
|
|
1061
|
-
for (const [_, part] of sortedParts) {
|
|
1062
|
-
combined.set(part.data, offset);
|
|
1063
|
-
offset += part.data.byteLength;
|
|
1064
|
-
}
|
|
1065
|
-
const fileVersion = this.makeFileVersion({
|
|
1066
|
-
bucketId: large.bucketId,
|
|
1067
|
-
fileName: large.fileName,
|
|
1068
|
-
contentType: large.contentType,
|
|
1069
|
-
contentLength: totalSize,
|
|
1070
|
-
contentSha1: "none",
|
|
1071
|
-
action: FileAction.Upload
|
|
1072
|
-
});
|
|
1073
|
-
const stored = { fileVersion, data: combined };
|
|
1074
|
-
const existing = bucket.files.get(large.fileName);
|
|
1075
|
-
if (existing) {
|
|
1076
|
-
existing.push(stored);
|
|
1077
|
-
} else {
|
|
1078
|
-
bucket.files.set(large.fileName, [stored]);
|
|
1079
|
-
}
|
|
1080
|
-
this.largeFiles.delete(req.fileId);
|
|
1081
|
-
this.firePostUploadHooks(fileVersion, large.bucketId, "b2:ObjectCreated:Upload");
|
|
1082
|
-
return { status: 200, body: fileVersion };
|
|
1083
|
-
}
|
|
1084
|
-
cancelLargeFile(req) {
|
|
1085
|
-
const large = this.largeFiles.get(req.fileId);
|
|
1086
|
-
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
1087
|
-
this.largeFiles.delete(req.fileId);
|
|
1088
|
-
return {
|
|
1089
|
-
status: 200,
|
|
1090
|
-
body: {
|
|
1091
|
-
fileId: large.fileId,
|
|
1092
|
-
accountId: this.accountId,
|
|
1093
|
-
bucketId: large.bucketId,
|
|
1094
|
-
fileName: large.fileName
|
|
1095
|
-
}
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
listUnfinishedLargeFiles(req) {
|
|
1099
|
-
const countError = validateMaxCount(req.maxFileCount, "b2_list_unfinished_large_files");
|
|
1100
|
-
if (countError) return this.error(400, countError.code, countError.message);
|
|
1101
|
-
const prefix = req.namePrefix ?? "";
|
|
1102
|
-
const max = req.maxFileCount ?? 100;
|
|
1103
|
-
const candidates = [...this.largeFiles.values()].filter((f) => f.bucketId === req.bucketId).filter((f) => f.fileName.startsWith(prefix)).sort((a, b) => a.fileName.localeCompare(b.fileName));
|
|
1104
|
-
let startIndex = 0;
|
|
1105
|
-
if (req.startFileId !== void 0) {
|
|
1106
|
-
const found = candidates.findIndex((f) => f.fileId === req.startFileId);
|
|
1107
|
-
startIndex = found >= 0 ? found : 0;
|
|
1108
|
-
}
|
|
1109
|
-
const slice = candidates.slice(startIndex, startIndex + max);
|
|
1110
|
-
const files = slice.map((f) => ({
|
|
1111
|
-
fileId: f.fileId,
|
|
1112
|
-
fileName: f.fileName,
|
|
1113
|
-
accountId: this.accountId,
|
|
1114
|
-
bucketId: f.bucketId,
|
|
1115
|
-
contentType: f.contentType,
|
|
1116
|
-
fileInfo: f.fileInfo
|
|
1117
|
-
}));
|
|
1118
|
-
const hasMore = startIndex + max < candidates.length;
|
|
1119
|
-
const nextFileId = hasMore ? candidates[startIndex + max]?.fileId ?? null : null;
|
|
1120
|
-
return { status: 200, body: { files, nextFileId } };
|
|
1121
|
-
}
|
|
1122
|
-
listParts(req) {
|
|
1123
|
-
const large = this.largeFiles.get(req.fileId);
|
|
1124
|
-
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
1125
|
-
const countError = validateMaxCount(req.maxPartCount, "b2_list_parts");
|
|
1126
|
-
if (countError) return this.error(400, countError.code, countError.message);
|
|
1127
|
-
const start = req.startPartNumber ?? 1;
|
|
1128
|
-
const max = req.maxPartCount ?? 1e3;
|
|
1129
|
-
const allParts = [...large.parts.entries()].filter(([n]) => n >= start).sort((a, b) => a[0] - b[0]).map(([partNumber, part]) => ({
|
|
1130
|
-
fileId: req.fileId,
|
|
1131
|
-
partNumber,
|
|
1132
|
-
contentLength: part.data.byteLength,
|
|
1133
|
-
contentSha1: part.sha1,
|
|
1134
|
-
uploadTimestamp: Date.now()
|
|
1135
|
-
}));
|
|
1136
|
-
const parts = allParts.slice(0, max);
|
|
1137
|
-
const nextPartNumber = allParts.length > max ? allParts[max]?.partNumber ?? null : null;
|
|
1138
|
-
return { status: 200, body: { parts, nextPartNumber } };
|
|
1139
|
-
}
|
|
1140
|
-
async copyPart(req) {
|
|
1141
|
-
const large = this.largeFiles.get(req.largeFileId);
|
|
1142
|
-
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
1143
|
-
const found = this.findFile(req.sourceFileId);
|
|
1144
|
-
if (found === null) return this.error(404, "file_not_present", "Source file not found");
|
|
1145
|
-
const sourceStored = found.stored;
|
|
1146
|
-
let partData = sourceStored.data;
|
|
1147
|
-
if (req.range) {
|
|
1148
|
-
const match = req.range.match(/bytes=(\d+)-(\d+)?/);
|
|
1149
|
-
if (match) {
|
|
1150
|
-
const rs = Number.parseInt(match[1] ?? "0", 10);
|
|
1151
|
-
const re = match[2] !== void 0 ? Number.parseInt(match[2], 10) : sourceStored.data.byteLength - 1;
|
|
1152
|
-
partData = sourceStored.data.slice(rs, re + 1);
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
const sha1 = await sha1Hex(partData);
|
|
1156
|
-
large.parts.set(req.partNumber, { data: new Uint8Array(partData), sha1 });
|
|
1157
|
-
return {
|
|
1158
|
-
status: 200,
|
|
1159
|
-
body: {
|
|
1160
|
-
fileId: req.largeFileId,
|
|
1161
|
-
partNumber: req.partNumber,
|
|
1162
|
-
contentLength: partData.byteLength,
|
|
1163
|
-
contentSha1: sha1
|
|
1164
|
-
}
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1167
|
-
getDownloadAuthorization(req) {
|
|
1168
|
-
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
1169
|
-
return {
|
|
1170
|
-
status: 200,
|
|
1171
|
-
body: {
|
|
1172
|
-
bucketId: req.bucketId,
|
|
1173
|
-
fileNamePrefix: req.fileNamePrefix,
|
|
1174
|
-
authorizationToken: `sim_dl_auth_${this.genId("tok")}`
|
|
1175
|
-
}
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
// --- Keys ---
|
|
1179
|
-
createKey(req) {
|
|
1180
|
-
const kid = this.genId("sim_key");
|
|
1181
|
-
const appKey = this.genId("sim_secret");
|
|
1182
|
-
const expiration = req.validDurationInSeconds !== void 0 ? Date.now() + req.validDurationInSeconds * 1e3 : null;
|
|
1183
|
-
const stored = {
|
|
1184
|
-
applicationKeyId: kid,
|
|
1185
|
-
keyName: req.keyName,
|
|
1186
|
-
capabilities: req.capabilities,
|
|
1187
|
-
accountId: req.accountId,
|
|
1188
|
-
applicationKey: appKey,
|
|
1189
|
-
bucketId: req.bucketId ?? null,
|
|
1190
|
-
namePrefix: req.namePrefix ?? null,
|
|
1191
|
-
expirationTimestamp: expiration
|
|
1192
|
-
};
|
|
1193
|
-
this.keys.set(kid, stored);
|
|
1194
|
-
return {
|
|
1195
|
-
status: 200,
|
|
1196
|
-
body: {
|
|
1197
|
-
keyName: stored.keyName,
|
|
1198
|
-
applicationKeyId: stored.applicationKeyId,
|
|
1199
|
-
applicationKey: stored.applicationKey,
|
|
1200
|
-
capabilities: stored.capabilities,
|
|
1201
|
-
accountId: stored.accountId,
|
|
1202
|
-
expirationTimestamp: stored.expirationTimestamp,
|
|
1203
|
-
bucketId: stored.bucketId,
|
|
1204
|
-
namePrefix: stored.namePrefix,
|
|
1205
|
-
options: []
|
|
1206
|
-
}
|
|
1207
|
-
};
|
|
1208
|
-
}
|
|
1209
|
-
listKeys(req) {
|
|
1210
|
-
const countError = validateMaxCount(req.maxKeyCount, "b2_list_keys");
|
|
1211
|
-
if (countError) return this.error(400, countError.code, countError.message);
|
|
1212
|
-
const max = req.maxKeyCount ?? 1e3;
|
|
1213
|
-
let allKeys = [...this.keys.values()].sort(
|
|
1214
|
-
(a, b) => a.applicationKeyId.localeCompare(b.applicationKeyId)
|
|
1215
|
-
);
|
|
1216
|
-
if (req.startApplicationKeyId) {
|
|
1217
|
-
const start = req.startApplicationKeyId;
|
|
1218
|
-
allKeys = allKeys.filter((k) => k.applicationKeyId >= start);
|
|
1219
|
-
}
|
|
1220
|
-
const keys = allKeys.slice(0, max).map((k) => ({
|
|
1221
|
-
keyName: k.keyName,
|
|
1222
|
-
applicationKeyId: k.applicationKeyId,
|
|
1223
|
-
capabilities: k.capabilities,
|
|
1224
|
-
accountId: k.accountId,
|
|
1225
|
-
expirationTimestamp: k.expirationTimestamp,
|
|
1226
|
-
bucketId: k.bucketId,
|
|
1227
|
-
namePrefix: k.namePrefix,
|
|
1228
|
-
options: []
|
|
1229
|
-
}));
|
|
1230
|
-
const nextId = allKeys.length > max ? allKeys[max]?.applicationKeyId ?? null : null;
|
|
1231
|
-
return { status: 200, body: { keys, nextApplicationKeyId: nextId } };
|
|
1232
|
-
}
|
|
1233
|
-
deleteKey(req) {
|
|
1234
|
-
const key = this.keys.get(req.applicationKeyId);
|
|
1235
|
-
if (!key) return this.error(400, "bad_request", "Key not found");
|
|
1236
|
-
this.keys.delete(req.applicationKeyId);
|
|
1237
|
-
for (const [token, scope] of this.issuedTokens.entries()) {
|
|
1238
|
-
if (scope.applicationKeyId === req.applicationKeyId) {
|
|
1239
|
-
this.issuedTokens.delete(token);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
return {
|
|
1243
|
-
status: 200,
|
|
1244
|
-
body: {
|
|
1245
|
-
keyName: key.keyName,
|
|
1246
|
-
applicationKeyId: key.applicationKeyId,
|
|
1247
|
-
capabilities: key.capabilities,
|
|
1248
|
-
accountId: key.accountId,
|
|
1249
|
-
expirationTimestamp: key.expirationTimestamp,
|
|
1250
|
-
bucketId: key.bucketId,
|
|
1251
|
-
namePrefix: key.namePrefix,
|
|
1252
|
-
options: []
|
|
1253
|
-
}
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
// --- File lock ---
|
|
1257
|
-
updateFileRetention(req) {
|
|
1258
|
-
const found = this.findFile(req.fileId);
|
|
1259
|
-
if (found === null || found.stored.fileVersion.fileName !== req.fileName) {
|
|
1260
|
-
return this.error(404, "file_not_present", "File not found");
|
|
1261
|
-
}
|
|
1262
|
-
found.versions[found.index] = {
|
|
1263
|
-
fileVersion: {
|
|
1264
|
-
...found.stored.fileVersion,
|
|
1265
|
-
fileRetention: { isClientAuthorizedToRead: true, value: req.fileRetention }
|
|
1266
|
-
},
|
|
1267
|
-
data: found.stored.data
|
|
1268
|
-
};
|
|
1269
|
-
return {
|
|
1270
|
-
status: 200,
|
|
1271
|
-
body: {
|
|
1272
|
-
fileName: req.fileName,
|
|
1273
|
-
fileId: req.fileId,
|
|
1274
|
-
fileRetention: req.fileRetention
|
|
1275
|
-
}
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
updateFileLegalHold(req) {
|
|
1279
|
-
const found = this.findFile(req.fileId);
|
|
1280
|
-
if (found === null || found.stored.fileVersion.fileName !== req.fileName) {
|
|
1281
|
-
return this.error(404, "file_not_present", "File not found");
|
|
1282
|
-
}
|
|
1283
|
-
found.versions[found.index] = {
|
|
1284
|
-
fileVersion: {
|
|
1285
|
-
...found.stored.fileVersion,
|
|
1286
|
-
legalHold: {
|
|
1287
|
-
isClientAuthorizedToRead: true,
|
|
1288
|
-
value: req.legalHold
|
|
1289
|
-
}
|
|
1290
|
-
},
|
|
1291
|
-
data: found.stored.data
|
|
1292
|
-
};
|
|
1293
|
-
return {
|
|
1294
|
-
status: 200,
|
|
1295
|
-
body: {
|
|
1296
|
-
fileName: req.fileName,
|
|
1297
|
-
fileId: req.fileId,
|
|
1298
|
-
legalHold: req.legalHold
|
|
1299
|
-
}
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
// --- Notifications ---
|
|
1303
|
-
getBucketNotificationRules(req) {
|
|
1304
|
-
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
1305
|
-
const rules = this.notificationRules.get(req.bucketId) ?? [];
|
|
1306
|
-
return { status: 200, body: { bucketId: req.bucketId, eventNotificationRules: rules } };
|
|
1307
|
-
}
|
|
1308
|
-
setBucketNotificationRules(req) {
|
|
1309
|
-
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
1310
|
-
this.notificationRules.set(req.bucketId, req.eventNotificationRules);
|
|
1311
|
-
return {
|
|
1312
|
-
status: 200,
|
|
1313
|
-
body: { bucketId: req.bucketId, eventNotificationRules: req.eventNotificationRules }
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
// --- Helpers ---
|
|
1317
|
-
/**
|
|
1318
|
-
* Locates a stored file version by its `fileId`, scanning every bucket.
|
|
1319
|
-
*
|
|
1320
|
-
* Returns enough context to support read-only inspection (`stored`,
|
|
1321
|
-
* `bucketId`) AND in-place mutation (`versions`, `index`) so callers
|
|
1322
|
-
* that need to splice the version out can do so without re-scanning.
|
|
1323
|
-
*
|
|
1324
|
-
* Real B2 fileIds embed the bucketId, so production lookups are O(1);
|
|
1325
|
-
* the simulator's flat ID generator (`this.genId('4_z')`) doesn't, so this
|
|
1326
|
-
* is O(buckets × files × versions). Acceptable for tests.
|
|
1327
|
-
*
|
|
1328
|
-
* @param fileId - The file version ID to locate.
|
|
1329
|
-
*
|
|
1330
|
-
* @returns The location of the matching version, or `null` if not found.
|
|
1331
|
-
*/
|
|
1332
|
-
findFile(fileId2) {
|
|
1333
|
-
for (const [bid, bucket] of this.buckets.entries()) {
|
|
1334
|
-
for (const versions of bucket.files.values()) {
|
|
1335
|
-
const idx = versions.findIndex((v) => v.fileVersion.fileId === fileId2);
|
|
1336
|
-
if (idx !== -1) {
|
|
1337
|
-
const stored = versions[idx];
|
|
1338
|
-
return { stored, bucketId: bid, bucket, versions, index: idx };
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
return null;
|
|
1343
|
-
}
|
|
1344
|
-
makeFileVersion(params) {
|
|
1345
|
-
return {
|
|
1346
|
-
accountId: accountId(this.accountId),
|
|
1347
|
-
action: params.action,
|
|
1348
|
-
bucketId: bucketId(params.bucketId),
|
|
1349
|
-
contentLength: params.contentLength,
|
|
1350
|
-
contentMd5: null,
|
|
1351
|
-
contentSha1: params.contentSha1,
|
|
1352
|
-
contentType: params.contentType,
|
|
1353
|
-
fileId: fileId(this.genId("4_z")),
|
|
1354
|
-
fileInfo: {},
|
|
1355
|
-
fileName: params.fileName,
|
|
1356
|
-
fileRetention: { isClientAuthorizedToRead: true, value: null },
|
|
1357
|
-
legalHold: { isClientAuthorizedToRead: true, value: null },
|
|
1358
|
-
replicationStatus: null,
|
|
1359
|
-
serverSideEncryption: { mode: EncryptionMode.None },
|
|
1360
|
-
uploadTimestamp: this.monotonicTimestamp()
|
|
1361
|
-
};
|
|
1362
|
-
}
|
|
1363
|
-
error(status, code, message) {
|
|
1364
|
-
return { status, body: { status, code, message } };
|
|
1365
|
-
}
|
|
1366
|
-
/**
|
|
1367
|
-
* Fire the pluggable post-upload hooks for a file that just landed in
|
|
1368
|
-
* a bucket: matching event-notification rules → `onWebhookDeliver`,
|
|
1369
|
-
* configured replication source rules → `onReplicate`. Errors thrown
|
|
1370
|
-
* from user-supplied hooks are swallowed so a buggy listener never
|
|
1371
|
-
* masks an otherwise-successful API response.
|
|
1372
|
-
*
|
|
1373
|
-
* Fired-and-forgotten (no await) by the handler so the synthetic
|
|
1374
|
-
* response is returned to the caller as fast as production B2 would
|
|
1375
|
-
* acknowledge the write.
|
|
1376
|
-
*
|
|
1377
|
-
* @param fileVersion - The freshly-stored file metadata.
|
|
1378
|
-
* @param bucketId - The bucket the upload landed in.
|
|
1379
|
-
* @param eventType - The B2 event-type tag (e.g. `'b2:ObjectCreated:Upload'`)
|
|
1380
|
-
* used to match against `EventNotificationRule.eventTypes` globs.
|
|
1381
|
-
*/
|
|
1382
|
-
firePostUploadHooks(fileVersion, bucketId2, eventType) {
|
|
1383
|
-
const bucket = this.buckets.get(bucketId2);
|
|
1384
|
-
if (!bucket) return;
|
|
1385
|
-
if (this.onWebhookDeliver !== void 0) {
|
|
1386
|
-
const rules = this.notificationRules.get(bucketId2) ?? [];
|
|
1387
|
-
for (const rule of rules) {
|
|
1388
|
-
if (!rule.isEnabled) continue;
|
|
1389
|
-
const matches = rule.eventTypes.some((pattern) => eventTypeMatches(pattern, eventType));
|
|
1390
|
-
if (!matches) continue;
|
|
1391
|
-
const hook = this.onWebhookDeliver;
|
|
1392
|
-
this.dispatchHook("webhook", () => hook({ rule, fileVersion, bucketId: bucketId2 }));
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
if (this.onReplicate !== void 0) {
|
|
1396
|
-
const replConfig = bucket.info.replicationConfiguration;
|
|
1397
|
-
const sourceRules = replConfig.asReplicationSource?.replicationRules ?? [];
|
|
1398
|
-
for (const rule of sourceRules) {
|
|
1399
|
-
if (!rule.isEnabled) continue;
|
|
1400
|
-
if (rule.fileNamePrefix && !fileVersion.fileName.startsWith(rule.fileNamePrefix)) continue;
|
|
1401
|
-
const hook = this.onReplicate;
|
|
1402
|
-
this.dispatchHook(
|
|
1403
|
-
"replication",
|
|
1404
|
-
() => hook({
|
|
1405
|
-
sourceFileVersion: fileVersion,
|
|
1406
|
-
sourceBucketId: bucketId2,
|
|
1407
|
-
destinationBucketId: rule.destinationBucketId
|
|
1408
|
-
})
|
|
1409
|
-
);
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
/**
|
|
1414
|
-
* Schedule a user-supplied hook callback. Errors are routed to
|
|
1415
|
-
* `onHookError` if configured, swallowed otherwise. The returned
|
|
1416
|
-
* promise is tracked in {@link pendingHooks} so `flushHooks()` can
|
|
1417
|
-
* wait for every dispatched hook to settle.
|
|
1418
|
-
*
|
|
1419
|
-
* @param kind - Which hook this is (`'webhook'` or `'replication'`).
|
|
1420
|
-
* @param fn - Thunk that performs the hook invocation.
|
|
1421
|
-
*/
|
|
1422
|
-
dispatchHook(kind, fn) {
|
|
1423
|
-
const task = Promise.resolve().then(() => fn()).catch((err) => {
|
|
1424
|
-
if (this.onHookError !== void 0) {
|
|
1425
|
-
this.onHookError({ kind, error: toError(err) });
|
|
1426
|
-
}
|
|
1427
|
-
}).finally(() => {
|
|
1428
|
-
this.pendingHooks.delete(task);
|
|
1429
|
-
});
|
|
1430
|
-
this.pendingHooks.add(task);
|
|
1431
|
-
}
|
|
325
|
+
function requestHeaderValue(headers, name) {
|
|
326
|
+
const exact = headers[name];
|
|
327
|
+
if (exact !== void 0) return exact;
|
|
328
|
+
const lowerName = name.toLowerCase();
|
|
329
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === lowerName) return value;
|
|
1432
330
|
}
|
|
1433
|
-
|
|
331
|
+
function parseContentLengthHeader(headers) {
|
|
332
|
+
const header = requestHeaderValue(headers, "content-length");
|
|
333
|
+
if (header === void 0) return { kind: "absent" };
|
|
334
|
+
if (!/^\d+$/.test(header)) return {
|
|
335
|
+
kind: "error",
|
|
336
|
+
message: `Content-Length must be a byte count: ${header}`
|
|
337
|
+
};
|
|
338
|
+
const expectedLength = Number(header);
|
|
339
|
+
if (!Number.isSafeInteger(expectedLength)) return {
|
|
340
|
+
kind: "error",
|
|
341
|
+
message: `Content-Length is too large: ${header}`
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
kind: "ok",
|
|
345
|
+
expectedLength
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function contentLengthMismatchMessage(expectedLength, actualLength) {
|
|
349
|
+
return `Content-Length ${expectedLength} does not match request body length ${actualLength}`;
|
|
350
|
+
}
|
|
351
|
+
function fileNames(...names) {
|
|
352
|
+
const present = names.filter((name) => name !== void 0);
|
|
353
|
+
return present.length > 0 ? present : void 0;
|
|
354
|
+
}
|
|
355
|
+
function queryParamsBody(params) {
|
|
356
|
+
const body = Object.create(null);
|
|
357
|
+
let hasParams = false;
|
|
358
|
+
for (const [key, value] of params) {
|
|
359
|
+
if (body[key] !== void 0) continue;
|
|
360
|
+
body[key] = value;
|
|
361
|
+
hasParams = true;
|
|
362
|
+
}
|
|
363
|
+
return hasParams ? body : null;
|
|
364
|
+
}
|
|
365
|
+
var JSON_GET_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
366
|
+
"b2_authorize_account",
|
|
367
|
+
"b2_list_groups",
|
|
368
|
+
"b2_list_group_members",
|
|
369
|
+
"bz_list_computers"
|
|
370
|
+
]);
|
|
371
|
+
var JSON_POST_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
372
|
+
"b2_create_group_member",
|
|
373
|
+
"b2_eject_group_member",
|
|
374
|
+
"b2_reserve_trial_create_account",
|
|
375
|
+
"bz_delete_computer",
|
|
376
|
+
"b2_create_bucket",
|
|
377
|
+
"b2_list_buckets",
|
|
378
|
+
"b2_delete_bucket",
|
|
379
|
+
"b2_update_bucket",
|
|
380
|
+
"b2_get_upload_url",
|
|
381
|
+
"b2_list_file_names",
|
|
382
|
+
"b2_list_file_versions",
|
|
383
|
+
"b2_get_file_info",
|
|
384
|
+
"b2_hide_file",
|
|
385
|
+
"b2_delete_file_version",
|
|
386
|
+
"b2_copy_file",
|
|
387
|
+
"b2_start_large_file",
|
|
388
|
+
"b2_get_upload_part_url",
|
|
389
|
+
"b2_finish_large_file",
|
|
390
|
+
"b2_cancel_large_file",
|
|
391
|
+
"b2_list_unfinished_large_files",
|
|
392
|
+
"b2_list_parts",
|
|
393
|
+
"b2_copy_part",
|
|
394
|
+
"b2_get_download_authorization",
|
|
395
|
+
"b2_create_key",
|
|
396
|
+
"b2_list_keys",
|
|
397
|
+
"b2_delete_key",
|
|
398
|
+
"b2_update_file_retention",
|
|
399
|
+
"b2_update_file_legal_hold",
|
|
400
|
+
"b2_get_bucket_notification_rules",
|
|
401
|
+
"b2_set_bucket_notification_rules"
|
|
402
|
+
]);
|
|
403
|
+
function jsonEndpointAllowsMethod(method, endpoint) {
|
|
404
|
+
const normalizedMethod = method.toUpperCase();
|
|
405
|
+
if (JSON_GET_ENDPOINTS.has(endpoint)) return normalizedMethod === "GET";
|
|
406
|
+
if (JSON_POST_ENDPOINTS.has(endpoint)) return normalizedMethod === "POST";
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
function notificationRulePrefixes(body) {
|
|
410
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
411
|
+
const rules = body["eventNotificationRules"];
|
|
412
|
+
if (!Array.isArray(rules)) return void 0;
|
|
413
|
+
return rules.map((rule) => {
|
|
414
|
+
if (typeof rule !== "object" || rule === null) return "";
|
|
415
|
+
const objectNamePrefix = rule["objectNamePrefix"];
|
|
416
|
+
return typeof objectNamePrefix === "string" ? objectNamePrefix : "";
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function storedNotificationRulePrefixes(rules) {
|
|
420
|
+
return (rules ?? []).map((rule) => typeof rule.objectNamePrefix === "string" ? rule.objectNamePrefix : "");
|
|
421
|
+
}
|
|
422
|
+
function hasKeyManagementCapability(capabilities) {
|
|
423
|
+
return capabilities.some((capability) => capability === Capability.ListKeys || capability === Capability.WriteKeys || capability === Capability.DeleteKeys);
|
|
424
|
+
}
|
|
425
|
+
function missingStoredKeyCapabilities(key, required) {
|
|
426
|
+
const capabilities = new Set(key.capabilities);
|
|
427
|
+
return required.filter((capability) => !capabilities.has(capability));
|
|
428
|
+
}
|
|
429
|
+
function storedKeyAllowsBucket(key, bucketId) {
|
|
430
|
+
return key.bucketIds === null || key.bucketIds.includes(bucketId);
|
|
431
|
+
}
|
|
432
|
+
function publicServerSideEncryption(encryption) {
|
|
433
|
+
if (encryption.mode === EncryptionMode.SseC) return {
|
|
434
|
+
mode: encryption.mode,
|
|
435
|
+
algorithm: encryption.algorithm
|
|
436
|
+
};
|
|
437
|
+
if (encryption.mode === EncryptionMode.None) return {
|
|
438
|
+
mode: null,
|
|
439
|
+
algorithm: null
|
|
440
|
+
};
|
|
441
|
+
return encryption;
|
|
442
|
+
}
|
|
443
|
+
function customerKeyDigest(customerKey) {
|
|
444
|
+
let first = 2166136261;
|
|
445
|
+
let second = 668265261;
|
|
446
|
+
for (const byte of customerKey) {
|
|
447
|
+
first = Math.imul(first ^ byte, 16777619) >>> 0;
|
|
448
|
+
second = Math.imul(second ^ byte, 2246822507) >>> 0;
|
|
449
|
+
}
|
|
450
|
+
return `${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}`;
|
|
451
|
+
}
|
|
452
|
+
var BASE64_ALPHABET = /^[A-Za-z0-9+/]+$/;
|
|
453
|
+
function normalizeSizedBase64(value, decodedByteLength) {
|
|
454
|
+
const paddedLength = Math.ceil(decodedByteLength / 3) * 4;
|
|
455
|
+
const noPaddingLength = Math.ceil(decodedByteLength * 8 / 6);
|
|
456
|
+
const padding = "=".repeat(paddedLength - noPaddingLength);
|
|
457
|
+
if (value.length === noPaddingLength && BASE64_ALPHABET.test(value)) return value.padEnd(paddedLength, "=");
|
|
458
|
+
if (value.length === paddedLength && value.endsWith(padding) && BASE64_ALPHABET.test(value.slice(0, noPaddingLength))) return value;
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function base64ToBytes(value, decodedByteLength) {
|
|
462
|
+
const normalized = normalizeSizedBase64(value, decodedByteLength);
|
|
463
|
+
if (normalized === null) return null;
|
|
464
|
+
try {
|
|
465
|
+
const decoded = atob(normalized);
|
|
466
|
+
if (decoded.length !== decodedByteLength) return null;
|
|
467
|
+
const bytes = new Uint8Array(decoded.length);
|
|
468
|
+
for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
|
|
469
|
+
return bytes;
|
|
470
|
+
} catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function encryptionValidationError(message) {
|
|
475
|
+
return {
|
|
476
|
+
status: 400,
|
|
477
|
+
body: {
|
|
478
|
+
status: 400,
|
|
479
|
+
code: "bad_request",
|
|
480
|
+
message
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function sameStoredServerSideEncryption(a, b) {
|
|
485
|
+
if (a.mode !== b.mode) return false;
|
|
486
|
+
if (a.mode === EncryptionMode.None) return true;
|
|
487
|
+
if (a.mode === EncryptionMode.SseB2 && b.mode === EncryptionMode.SseB2) return a.algorithm === b.algorithm;
|
|
488
|
+
if (a.mode === EncryptionMode.SseC && b.mode === EncryptionMode.SseC) return a.algorithm === b.algorithm && a.customerKeyDigest === b.customerKeyDigest && a.customerKeyMd5 === b.customerKeyMd5;
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
function hasCustomerEncryptionFields(encryption) {
|
|
492
|
+
return encryption["customerKey"] !== void 0 || encryption["customerKeyMd5"] !== void 0 || encryption["customerAlgorithm"] !== void 0;
|
|
493
|
+
}
|
|
494
|
+
function hasCustomerEncryptionHeaders(headers) {
|
|
495
|
+
return headers["x-bz-server-side-encryption-customer-algorithm"] !== void 0 || headers["x-bz-server-side-encryption-customer-key"] !== void 0 || headers["x-bz-server-side-encryption-customer-key-md5"] !== void 0;
|
|
496
|
+
}
|
|
497
|
+
async function storedServerSideEncryption(encryption) {
|
|
498
|
+
const runtimeEncryption = encryption;
|
|
499
|
+
if (encryption.mode === EncryptionMode.SseC) {
|
|
500
|
+
if (runtimeEncryption["algorithm"] === void 0) return encryptionValidationError("SSE-C customer algorithm is required");
|
|
501
|
+
if (encryption.algorithm !== EncryptionAlgorithm.Aes256) return encryptionValidationError("SSE-C customer algorithm must be AES256");
|
|
502
|
+
const customerKey = encryption.customerKey;
|
|
503
|
+
const customerKeyMd5 = encryption.customerKeyMd5;
|
|
504
|
+
if (typeof customerKey !== "string" || customerKey === "") return encryptionValidationError("SSE-C customer key is required");
|
|
505
|
+
if (typeof customerKeyMd5 !== "string" || customerKeyMd5 === "") return encryptionValidationError("SSE-C customer key MD5 is required");
|
|
506
|
+
const customerKeyBytes = base64ToBytes(customerKey, 32);
|
|
507
|
+
if (customerKeyBytes === null) return encryptionValidationError("SSE-C customer key must be a base64-encoded 256-bit key");
|
|
508
|
+
const normalizedCustomerKeyMd5 = normalizeSizedBase64(customerKeyMd5, 16);
|
|
509
|
+
if (normalizedCustomerKeyMd5 === null) return encryptionValidationError("SSE-C customer key MD5 must be a base64-encoded 128-bit digest");
|
|
510
|
+
if (await md5Base64(customerKeyBytes) !== normalizedCustomerKeyMd5) return encryptionValidationError("SSE-C customer key MD5 does not match the key");
|
|
511
|
+
return {
|
|
512
|
+
mode: encryption.mode,
|
|
513
|
+
algorithm: encryption.algorithm,
|
|
514
|
+
customerKeyDigest: customerKeyDigest(customerKeyBytes),
|
|
515
|
+
customerKeyMd5: normalizedCustomerKeyMd5
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (encryption.mode === EncryptionMode.SseB2) {
|
|
519
|
+
if (hasCustomerEncryptionFields(runtimeEncryption)) return encryptionValidationError("SSE-B2 settings must not include SSE-C customer keys");
|
|
520
|
+
if (encryption.algorithm !== EncryptionAlgorithm.Aes256) return encryptionValidationError("SSE-B2 algorithm must be AES256");
|
|
521
|
+
return {
|
|
522
|
+
mode: encryption.mode,
|
|
523
|
+
algorithm: encryption.algorithm
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (encryption.mode === EncryptionMode.None) {
|
|
527
|
+
if (hasCustomerEncryptionFields(runtimeEncryption)) return encryptionValidationError("No-encryption settings must not include SSE-C customer keys");
|
|
528
|
+
return { mode: EncryptionMode.None };
|
|
529
|
+
}
|
|
530
|
+
return encryptionValidationError(`Unsupported server-side encryption mode: ${String(runtimeEncryption["mode"])}`);
|
|
531
|
+
}
|
|
532
|
+
async function uploadServerSideEncryption(headers, fallback) {
|
|
533
|
+
const customerAlgorithm = headers["x-bz-server-side-encryption-customer-algorithm"];
|
|
534
|
+
const customerKey = headers["x-bz-server-side-encryption-customer-key"];
|
|
535
|
+
const customerKeyMd5 = headers["x-bz-server-side-encryption-customer-key-md5"];
|
|
536
|
+
const managedEncryption = headers["x-bz-server-side-encryption"];
|
|
537
|
+
if (hasCustomerEncryptionHeaders(headers)) {
|
|
538
|
+
if (managedEncryption !== void 0) return encryptionValidationError("SSE-B2 settings must not include SSE-C customer keys");
|
|
539
|
+
if (customerAlgorithm === void 0) return encryptionValidationError("SSE-C customer algorithm is required");
|
|
540
|
+
if (customerAlgorithm !== EncryptionAlgorithm.Aes256) return encryptionValidationError("SSE-C customer algorithm must be AES256");
|
|
541
|
+
if (!customerKey || !customerKeyMd5) return encryptionValidationError("SSE-C customer key and MD5 are required");
|
|
542
|
+
return await storedServerSideEncryption({
|
|
543
|
+
mode: EncryptionMode.SseC,
|
|
544
|
+
algorithm: EncryptionAlgorithm.Aes256,
|
|
545
|
+
customerKey,
|
|
546
|
+
customerKeyMd5
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (managedEncryption !== void 0) {
|
|
550
|
+
if (managedEncryption !== EncryptionAlgorithm.Aes256) return encryptionValidationError("SSE-B2 algorithm must be AES256");
|
|
551
|
+
return {
|
|
552
|
+
mode: EncryptionMode.SseB2,
|
|
553
|
+
algorithm: EncryptionAlgorithm.Aes256
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return await storedServerSideEncryption(fallback);
|
|
557
|
+
}
|
|
558
|
+
function defaultFileRetention(policy, uploadTimestamp) {
|
|
559
|
+
if (policy.mode === BucketRetentionMode.None || policy.period === null) return null;
|
|
560
|
+
const retainUntilTimestamp = uploadTimestamp + (policy.period.unit === "days" ? policy.period.duration : policy.period.duration * 365) * MS_PER_DAY;
|
|
561
|
+
if (!isValidRetentionTimestamp(retainUntilTimestamp, uploadTimestamp)) return null;
|
|
562
|
+
return {
|
|
563
|
+
mode: policy.mode,
|
|
564
|
+
retainUntilTimestamp
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function isRetentionMode(value) {
|
|
568
|
+
return value === RetentionMode.Compliance || value === RetentionMode.Governance;
|
|
569
|
+
}
|
|
570
|
+
function parseFileRetentionValue(value, now) {
|
|
571
|
+
if (typeof value !== "object" || value === null) return null;
|
|
572
|
+
const candidate = value;
|
|
573
|
+
const mode = candidate["mode"];
|
|
574
|
+
const retainUntilTimestamp = candidate["retainUntilTimestamp"];
|
|
575
|
+
if (mode === null && retainUntilTimestamp === null) return {
|
|
576
|
+
mode,
|
|
577
|
+
retainUntilTimestamp
|
|
578
|
+
};
|
|
579
|
+
if (isRetentionMode(mode) && typeof retainUntilTimestamp === "number") {
|
|
580
|
+
if (!isValidRetentionTimestamp(retainUntilTimestamp, now)) return null;
|
|
581
|
+
return {
|
|
582
|
+
mode,
|
|
583
|
+
retainUntilTimestamp
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
function isRetentionWeakened(current, next) {
|
|
589
|
+
if (current === null || current === void 0) return false;
|
|
590
|
+
if (current.mode === null) return false;
|
|
591
|
+
if (next.mode === null) return true;
|
|
592
|
+
if (current.mode === RetentionMode.Compliance && next.mode !== RetentionMode.Compliance) return true;
|
|
593
|
+
if (current.retainUntilTimestamp === null) return next.mode !== current.mode;
|
|
594
|
+
if (next.retainUntilTimestamp === null) return true;
|
|
595
|
+
return next.retainUntilTimestamp < current.retainUntilTimestamp;
|
|
596
|
+
}
|
|
597
|
+
function requiresGovernanceBypass(current, next) {
|
|
598
|
+
if (current?.mode !== RetentionMode.Governance) return false;
|
|
599
|
+
if (next.mode === RetentionMode.Compliance) return true;
|
|
600
|
+
return isRetentionWeakened(current, next);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* In-memory B2 simulator for testing. Implements the B2 native API at the
|
|
604
|
+
* request/response level without any network I/O. Supports 25+ operations
|
|
605
|
+
* including buckets, files, large files, keys, and notifications.
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* ```ts
|
|
609
|
+
* const sim = new B2Simulator()
|
|
610
|
+
* const client = new B2Client({
|
|
611
|
+
* applicationKeyId: 'test-key-id',
|
|
612
|
+
* applicationKey: 'test-key',
|
|
613
|
+
* transport: sim.transport(),
|
|
614
|
+
* })
|
|
615
|
+
* await client.authorize()
|
|
616
|
+
* ```
|
|
617
|
+
*
|
|
618
|
+
* `test-key-id:test-key` is the documented implicit full-access
|
|
619
|
+
* storage credential. `master-key-id:master-key` is also accepted for
|
|
620
|
+
* tests that share storage and Partner simulator credentials. In the
|
|
621
|
+
* default permissive mode, other well-formed Basic credentials whose
|
|
622
|
+
* applicationKeyId was not minted by `b2_create_key` receive the same
|
|
623
|
+
* implicit master grant for backward compatibility. A simulator-created
|
|
624
|
+
* key always authorizes with its own capabilities, bucket restrictions,
|
|
625
|
+
* name prefix, and expiration.
|
|
626
|
+
*/
|
|
627
|
+
var B2Simulator = class {
|
|
628
|
+
buckets = /* @__PURE__ */ new Map();
|
|
629
|
+
accountId = "sim_account_0001";
|
|
630
|
+
largeFiles = /* @__PURE__ */ new Map();
|
|
631
|
+
keys = /* @__PURE__ */ new Map();
|
|
632
|
+
notificationRules = /* @__PURE__ */ new Map();
|
|
633
|
+
minimumPartSize;
|
|
634
|
+
recommendedPartSize;
|
|
635
|
+
faults = [];
|
|
636
|
+
nextFaultId = 1;
|
|
637
|
+
onWebhookDeliver;
|
|
638
|
+
onReplicate;
|
|
639
|
+
onHookError;
|
|
640
|
+
strictAuth;
|
|
641
|
+
authTokenTtlMs;
|
|
642
|
+
customUploadTimestampsEnabled;
|
|
643
|
+
partner;
|
|
644
|
+
/**
|
|
645
|
+
* Issued auth tokens with their associated grant scope + expiry. In
|
|
646
|
+
* permissive mode (`strictAuth: false`) this is still populated by
|
|
647
|
+
* `authorize` but never consulted on subsequent requests. In strict
|
|
648
|
+
* mode each request looks up its `Authorization` header here.
|
|
649
|
+
*/
|
|
650
|
+
issuedTokens = /* @__PURE__ */ new Map();
|
|
651
|
+
/**
|
|
652
|
+
* Mutable upload-token overrides for tokens minted by `b2_get_upload_url`
|
|
653
|
+
* and `b2_get_upload_part_url`. The token string is self-describing
|
|
654
|
+
* and signed so another simulator instance can validate issued-token
|
|
655
|
+
* state, while this map records local invalidation / forced-expiry
|
|
656
|
+
* state for tests.
|
|
657
|
+
*/
|
|
658
|
+
uploadTokens = /* @__PURE__ */ new Map();
|
|
659
|
+
downloadAuthorizationTokens = /* @__PURE__ */ new Map();
|
|
660
|
+
downloadAuthorizationExpiryHeap = [];
|
|
661
|
+
/**
|
|
662
|
+
* Virtual-clock offset applied to `Date.now()` for expiry checks.
|
|
663
|
+
* Defaults to 0. Tests advance via {@link advanceTime} to fast-forward
|
|
664
|
+
* past auth-token expiry without sleeping.
|
|
665
|
+
*/
|
|
666
|
+
clockOffsetMs = 0;
|
|
667
|
+
/**
|
|
668
|
+
* Per-instance monotonic counter used to mint realistic-looking
|
|
669
|
+
* IDs (`b2_bucket_<24-hex>`, `4_z<24-hex>`, etc.) and auth tokens.
|
|
670
|
+
* Module-global previously, which leaked state across `B2Simulator`
|
|
671
|
+
* instances in the same Vitest worker — concurrent tests could see
|
|
672
|
+
* colliding tokens. Per-instance ensures determinism within a single
|
|
673
|
+
* simulator's lifetime.
|
|
674
|
+
*/
|
|
675
|
+
nextId = 1;
|
|
676
|
+
/**
|
|
677
|
+
* Last-issued upload timestamp. The simulator enforces strict
|
|
678
|
+
* monotonicity so version ordering is deterministic for tests; if
|
|
679
|
+
* two writes hit the same `Date.now()` millisecond, the second
|
|
680
|
+
* receives `lastTimestamp + 1`.
|
|
681
|
+
*/
|
|
682
|
+
lastTimestamp = 0;
|
|
683
|
+
/**
|
|
684
|
+
* Outstanding fire-and-forget hook invocations. Tracked so tests
|
|
685
|
+
* (and future {@link flushHooks} callers) can wait for every
|
|
686
|
+
* dispatched `onWebhookDeliver` / `onReplicate` callback to settle
|
|
687
|
+
* before asserting against observable side effects. The microtask
|
|
688
|
+
* dance in fidelity tests was previously brittle: positive cases
|
|
689
|
+
* flushed twice, negative cases flushed once, and any addition to
|
|
690
|
+
* the hook dispatch chain quietly broke the negative path.
|
|
691
|
+
*/
|
|
692
|
+
pendingHooks = /* @__PURE__ */ new Set();
|
|
693
|
+
/**
|
|
694
|
+
* Constructs a new in-memory B2 simulator.
|
|
695
|
+
* @param options - Optional simulator overrides. See {@link B2SimulatorOptions}.
|
|
696
|
+
*/
|
|
697
|
+
constructor(options = {}) {
|
|
698
|
+
this.minimumPartSize = options.minimumPartSize ?? 5e6;
|
|
699
|
+
this.recommendedPartSize = options.recommendedPartSize ?? 1e8;
|
|
700
|
+
if (options.onWebhookDeliver !== void 0) this.onWebhookDeliver = options.onWebhookDeliver;
|
|
701
|
+
if (options.onReplicate !== void 0) this.onReplicate = options.onReplicate;
|
|
702
|
+
if (options.onHookError !== void 0) this.onHookError = options.onHookError;
|
|
703
|
+
this.strictAuth = options.strictAuth ?? false;
|
|
704
|
+
this.customUploadTimestampsEnabled = options.customUploadTimestampsEnabled ?? false;
|
|
705
|
+
this.authTokenTtlMs = options.authTokenTtlMs ?? 864e5;
|
|
706
|
+
this.partner = new PartnerSimulator({
|
|
707
|
+
accountId: this.accountId,
|
|
708
|
+
authTokenTtlMs: this.authTokenTtlMs,
|
|
709
|
+
minimumPartSize: this.minimumPartSize,
|
|
710
|
+
recommendedPartSize: this.recommendedPartSize,
|
|
711
|
+
canAuthorize: (authzHeader) => this.canAuthorizePartner(authzHeader),
|
|
712
|
+
createBucket: (req) => this.createBucket(req),
|
|
713
|
+
error: (status, code, message) => this.error(status, code, message),
|
|
714
|
+
genId: (prefix) => this.genId(prefix),
|
|
715
|
+
monotonicTimestamp: () => this.monotonicTimestamp(),
|
|
716
|
+
now: () => this.now(),
|
|
717
|
+
storeKey: (key) => {
|
|
718
|
+
this.keys.set(key.applicationKeyId, key);
|
|
719
|
+
}
|
|
720
|
+
}, options);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Advance the simulator's virtual clock by `ms` milliseconds. Used in
|
|
724
|
+
* conjunction with `strictAuth: true` + a finite `authTokenTtlMs` to
|
|
725
|
+
* force token expiry without `setTimeout`-based delays.
|
|
726
|
+
*
|
|
727
|
+
* @param ms - Milliseconds to advance. Negative values rewind (rarely useful).
|
|
728
|
+
*/
|
|
729
|
+
advanceTime(ms) {
|
|
730
|
+
this.clockOffsetMs += ms;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Expire an upload authorization token previously returned by
|
|
734
|
+
* `b2_get_upload_url` or `b2_get_upload_part_url`. Tests can use this
|
|
735
|
+
* to simulate a stale upload URL and verify retry paths that fetch a
|
|
736
|
+
* fresh URL/token pair.
|
|
737
|
+
*
|
|
738
|
+
* @param authorizationToken - The upload authorization token to expire.
|
|
739
|
+
*
|
|
740
|
+
* @returns `true` when the token existed and was expired, otherwise `false`.
|
|
741
|
+
*/
|
|
742
|
+
expireUploadToken(authorizationToken) {
|
|
743
|
+
const now = this.now();
|
|
744
|
+
const token = this.uploadTokens.get(authorizationToken);
|
|
745
|
+
if (token === void 0 || now >= token.cleanupAt) return false;
|
|
746
|
+
token.expiresAt = now - 1;
|
|
747
|
+
this.uploadTokens.set(authorizationToken, token);
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Invalidate an upload authorization token previously returned by
|
|
752
|
+
* `b2_get_upload_url` or `b2_get_upload_part_url`.
|
|
753
|
+
*
|
|
754
|
+
* @param authorizationToken - The upload authorization token to invalidate.
|
|
755
|
+
*
|
|
756
|
+
* @returns `true` when the token existed and was invalidated, otherwise `false`.
|
|
757
|
+
*/
|
|
758
|
+
invalidateUploadToken(authorizationToken) {
|
|
759
|
+
const now = this.now();
|
|
760
|
+
const token = this.uploadTokens.get(authorizationToken);
|
|
761
|
+
if (token === void 0 || now >= token.cleanupAt) {
|
|
762
|
+
this.pruneExpiredUploadTokens(now);
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
token.invalidated = true;
|
|
766
|
+
this.uploadTokens.set(authorizationToken, token);
|
|
767
|
+
this.pruneExpiredUploadTokens();
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Current simulator time. Equal to `Date.now() + clockOffsetMs`.
|
|
772
|
+
*
|
|
773
|
+
* @returns Unix milliseconds.
|
|
774
|
+
*/
|
|
775
|
+
now() {
|
|
776
|
+
return Date.now() + this.clockOffsetMs;
|
|
777
|
+
}
|
|
778
|
+
isStoredKeyExpired(key) {
|
|
779
|
+
return key.expirationTimestamp !== null && key.expirationTimestamp <= this.now();
|
|
780
|
+
}
|
|
781
|
+
pruneExpiredUploadTokens(now = this.now()) {
|
|
782
|
+
for (const [authorizationToken, token] of this.uploadTokens.entries()) if (now >= token.cleanupAt) this.uploadTokens.delete(authorizationToken);
|
|
783
|
+
}
|
|
784
|
+
async uploadTokenState(authorizationToken) {
|
|
785
|
+
const stored = this.uploadTokens.get(authorizationToken);
|
|
786
|
+
if (stored !== void 0) return stored;
|
|
787
|
+
return await this.decodeUploadAuthorizationToken(authorizationToken);
|
|
788
|
+
}
|
|
789
|
+
async encodeUploadAuthorizationToken(token) {
|
|
790
|
+
const payload = {
|
|
791
|
+
v: 1,
|
|
792
|
+
kind: token.kind,
|
|
793
|
+
fileName: token.fileName,
|
|
794
|
+
uploadUrl: token.uploadUrl,
|
|
795
|
+
namePrefix: token.namePrefix,
|
|
796
|
+
applicationKeyId: token.applicationKeyId,
|
|
797
|
+
expiresAt: token.expiresAt
|
|
798
|
+
};
|
|
799
|
+
const encoded = base64UrlEncode(utf8Encoder.encode(JSON.stringify(payload)));
|
|
800
|
+
const signature = await this.signUploadTokenPayload(encoded);
|
|
801
|
+
return `${token.kind === "file" ? "sim_upload_auth" : "sim_part_auth"}_${encoded}.${signature}`;
|
|
802
|
+
}
|
|
803
|
+
async decodeUploadAuthorizationToken(authorizationToken) {
|
|
804
|
+
const expectedKind = authorizationToken.startsWith("sim_upload_auth_") ? "file" : authorizationToken.startsWith("sim_part_auth_") ? "part" : null;
|
|
805
|
+
if (expectedKind === null) return null;
|
|
806
|
+
const tokenParts = authorizationToken.slice(expectedKind === "file" ? 16 : 14).split(".");
|
|
807
|
+
if (tokenParts.length !== 2) return null;
|
|
808
|
+
const [payloadBytes, signature] = tokenParts;
|
|
809
|
+
if (!timingSafeStringEqual(signature, await this.signUploadTokenPayload(payloadBytes))) return null;
|
|
810
|
+
let parsed;
|
|
811
|
+
try {
|
|
812
|
+
const bytes = base64UrlDecode(payloadBytes);
|
|
813
|
+
if (bytes === null) return null;
|
|
814
|
+
parsed = JSON.parse(utf8Decoder.decode(bytes));
|
|
815
|
+
} catch {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
819
|
+
const payload = parsed;
|
|
820
|
+
if (payload["v"] !== 1 || payload["kind"] !== expectedKind || typeof payload["uploadUrl"] !== "string" || typeof payload["expiresAt"] !== "number" || !Number.isFinite(payload["expiresAt"]) || !(payload["namePrefix"] === null || typeof payload["namePrefix"] === "string") || !(payload["applicationKeyId"] === null || typeof payload["applicationKeyId"] === "string")) return null;
|
|
821
|
+
let fileName;
|
|
822
|
+
if (expectedKind === "part") {
|
|
823
|
+
if (typeof payload["fileName"] !== "string") return null;
|
|
824
|
+
fileName = payload["fileName"];
|
|
825
|
+
} else {
|
|
826
|
+
if (payload["fileName"] !== null) return null;
|
|
827
|
+
fileName = null;
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
kind: expectedKind,
|
|
831
|
+
fileName,
|
|
832
|
+
uploadUrl: payload["uploadUrl"],
|
|
833
|
+
namePrefix: payload["namePrefix"],
|
|
834
|
+
applicationKeyId: payload["applicationKeyId"],
|
|
835
|
+
expiresAt: payload["expiresAt"],
|
|
836
|
+
cleanupAt: payload["expiresAt"],
|
|
837
|
+
invalidated: false
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
async signUploadTokenPayload(encodedPayload) {
|
|
841
|
+
return base64UrlEncode(await hmacSha256(UPLOAD_TOKEN_SIGNING_KEY, encodedPayload));
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Generate a deterministic-but-realistic-looking B2 identifier of the
|
|
845
|
+
* shape `<prefix>_<24-hex-counter>`, which approximates the visual
|
|
846
|
+
* width of real B2 wire IDs. The counter is monotonic per instance
|
|
847
|
+
* so test fixtures are deterministic across runs of the same
|
|
848
|
+
* simulator.
|
|
849
|
+
*
|
|
850
|
+
* @param prefix - B2-style prefix (`'b2_bucket'`, `'4_z'`, etc.).
|
|
851
|
+
*
|
|
852
|
+
* @returns A simulator-issued identifier that looks like a B2 wire ID.
|
|
853
|
+
*/
|
|
854
|
+
genId(prefix) {
|
|
855
|
+
return `${prefix}_${(this.nextId++).toString(16).padStart(24, "0")}`;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Return a strictly-increasing upload timestamp, even when multiple
|
|
859
|
+
* writes land in the same `Date.now()` millisecond. Tests rely on
|
|
860
|
+
* `uploadTimestamp` ordering to discriminate file versions; ties from
|
|
861
|
+
* coarse system-clock resolution would otherwise make version
|
|
862
|
+
* selection nondeterministic.
|
|
863
|
+
*
|
|
864
|
+
* @returns Unix milliseconds.
|
|
865
|
+
*/
|
|
866
|
+
monotonicTimestamp() {
|
|
867
|
+
const now = Date.now();
|
|
868
|
+
if (now <= this.lastTimestamp) this.lastTimestamp += 1;
|
|
869
|
+
else this.lastTimestamp = now;
|
|
870
|
+
return this.lastTimestamp;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Wait for every fire-and-forget hook (`onWebhookDeliver`,
|
|
874
|
+
* `onReplicate`) currently in flight to settle. Use in tests after
|
|
875
|
+
* an upload/copy/finish-large-file call to deterministically observe
|
|
876
|
+
* hook side effects without microtask-flush guesswork.
|
|
877
|
+
*
|
|
878
|
+
* @returns A promise that resolves once every pending hook callback
|
|
879
|
+
* has either resolved or rejected.
|
|
880
|
+
*/
|
|
881
|
+
async flushHooks() {
|
|
882
|
+
while (this.pendingHooks.size > 0) {
|
|
883
|
+
const snapshot = [...this.pendingHooks];
|
|
884
|
+
await Promise.allSettled(snapshot);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Authorize a request against the strict-auth bookkeeping. Returns
|
|
889
|
+
* a `SimulatorJsonResponse` error on failure or `null` on success.
|
|
890
|
+
* Only consulted when `strictAuth: true`.
|
|
891
|
+
*
|
|
892
|
+
* @param authToken - The `Authorization` header value from the request.
|
|
893
|
+
* @param endpoint - B2 endpoint name being invoked.
|
|
894
|
+
* @param scope - Optional effective bucket and file-name scope derived from the request.
|
|
895
|
+
*
|
|
896
|
+
* @returns `null` when the request is authorized, otherwise a 401/403 response.
|
|
897
|
+
*/
|
|
898
|
+
authorizeRequest(authToken, endpoint, scope) {
|
|
899
|
+
if (missingCapabilitiesFor(endpoint, []).length === 0 && endpoint === "b2_authorize_account") return null;
|
|
900
|
+
if (authToken === void 0 || authToken === "") return this.error(401, "bad_auth_token", "missing Authorization header");
|
|
901
|
+
const token = this.issuedTokens.get(authToken);
|
|
902
|
+
if (!token) return this.error(401, "bad_auth_token", "unknown auth token");
|
|
903
|
+
if (this.now() > token.expiresAt) return this.error(401, "expired_auth_token", "auth token has expired; reauthorize");
|
|
904
|
+
if (token.applicationKeyId !== null) {
|
|
905
|
+
const key = this.keys.get(token.applicationKeyId);
|
|
906
|
+
if (key === void 0) return this.error(401, "bad_auth_token", "application key has been deleted; reauthorize");
|
|
907
|
+
if (this.isStoredKeyExpired(key)) return this.error(401, "expired_auth_token", "application key has expired; reauthorize");
|
|
908
|
+
}
|
|
909
|
+
const missing = missingCapabilitiesFor(endpoint, token.capabilities);
|
|
910
|
+
if (missing.length > 0) return this.error(403, "unauthorized", `application key lacks required capabilities: ${missing.join(", ")}`);
|
|
911
|
+
return this.authorizeScopeGrant(scope, {
|
|
912
|
+
bucketIds: token.bucketIds,
|
|
913
|
+
namePrefix: token.namePrefix,
|
|
914
|
+
bucketScopeRequiredMessage: () => `application key is scoped to buckets ${token.bucketIds?.join(", ")}; bucket scope is required`,
|
|
915
|
+
bucketMismatchMessage: (bucketId) => `application key is scoped to buckets ${token.bucketIds?.join(", ")}; cannot access ${bucketId}`,
|
|
916
|
+
prefixMismatchMessage: (fileName) => `application key is scoped to prefix "${token.namePrefix}"; "${fileName}" is outside scope`
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
authorizeScopeGrant(scope, grant) {
|
|
920
|
+
if (grant.bucketIds !== null) {
|
|
921
|
+
if (scope?.requiresAccountLevelBucketAccess === true) return this.error(403, "unauthorized", grant.bucketScopeRequiredMessage());
|
|
922
|
+
if ((scope?.bucketIds.length ?? 0) === 0 && scope?.requiresBucketScope === true) return this.error(403, "unauthorized", grant.bucketScopeRequiredMessage());
|
|
923
|
+
for (const bucketId of scope?.bucketIds ?? []) {
|
|
924
|
+
if (grant.bucketIds.includes(bucketId)) continue;
|
|
925
|
+
return this.error(403, "unauthorized", grant.bucketMismatchMessage(bucketId));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (grant.namePrefix !== null) for (const fileName of scope?.fileNames ?? []) {
|
|
929
|
+
if (fileName.startsWith(grant.namePrefix)) continue;
|
|
930
|
+
return this.error(403, "unauthorized", grant.prefixMismatchMessage(fileName));
|
|
931
|
+
}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
authorizeDownloadRequest(authToken, endpoint, scope, url) {
|
|
935
|
+
if (authToken === void 0 || authToken === "") return this.error(401, "bad_auth_token", "missing authorization token");
|
|
936
|
+
const downloadAuth = this.downloadAuthorizationTokens.get(authToken);
|
|
937
|
+
if (downloadAuth === void 0) {
|
|
938
|
+
this.purgeExpiredDownloadAuthorizationTokens();
|
|
939
|
+
return this.authorizeRequest(authToken, endpoint, scope);
|
|
940
|
+
}
|
|
941
|
+
if (this.isExpiredDownloadAuthorizationToken(downloadAuth)) {
|
|
942
|
+
this.downloadAuthorizationTokens.delete(authToken);
|
|
943
|
+
this.purgeExpiredDownloadAuthorizationTokens();
|
|
944
|
+
return this.error(401, "expired_auth_token", "download authorization token has expired");
|
|
945
|
+
}
|
|
946
|
+
if (endpoint === "b2_download_file_by_id") return this.error(403, "unauthorized", "download authorization tokens cannot be used with b2_download_file_by_id");
|
|
947
|
+
const overrideError = this.authorizeDownloadResponseOverrides(downloadAuth, url);
|
|
948
|
+
if (overrideError !== null) return overrideError;
|
|
949
|
+
return this.authorizeScopeGrant(scope, {
|
|
950
|
+
bucketIds: [downloadAuth.bucketId],
|
|
951
|
+
namePrefix: downloadAuth.fileNamePrefix,
|
|
952
|
+
bucketScopeRequiredMessage: () => `download authorization is scoped to bucket ${downloadAuth.bucketId}; bucket scope is required`,
|
|
953
|
+
bucketMismatchMessage: (bucketId) => `download authorization is scoped to bucket ${downloadAuth.bucketId}; cannot access ${bucketId}`,
|
|
954
|
+
prefixMismatchMessage: (fileName) => `download authorization is scoped to prefix "${downloadAuth.fileNamePrefix}"; "${fileName}" is outside scope`
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
isExpiredDownloadAuthorizationToken(token, now = this.now()) {
|
|
958
|
+
return now >= token.expiresAt;
|
|
959
|
+
}
|
|
960
|
+
purgeExpiredDownloadAuthorizationTokens() {
|
|
961
|
+
const now = this.now();
|
|
962
|
+
for (let i = 0; i < DOWNLOAD_AUTH_PURGE_BATCH_SIZE; i++) {
|
|
963
|
+
const next = this.downloadAuthorizationExpiryHeap[0];
|
|
964
|
+
if (next === void 0 || next.expiresAt > now) return;
|
|
965
|
+
this.popDownloadAuthorizationExpiry();
|
|
966
|
+
if (this.downloadAuthorizationTokens.get(next.token)?.expiresAt === next.expiresAt) this.downloadAuthorizationTokens.delete(next.token);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
pushDownloadAuthorizationExpiry(entry) {
|
|
970
|
+
const heap = this.downloadAuthorizationExpiryHeap;
|
|
971
|
+
heap.push(entry);
|
|
972
|
+
let idx = heap.length - 1;
|
|
973
|
+
while (idx > 0) {
|
|
974
|
+
const parentIdx = Math.floor((idx - 1) / 2);
|
|
975
|
+
const parent = heap[parentIdx];
|
|
976
|
+
if (parent.expiresAt <= entry.expiresAt) break;
|
|
977
|
+
heap[idx] = parent;
|
|
978
|
+
idx = parentIdx;
|
|
979
|
+
}
|
|
980
|
+
heap[idx] = entry;
|
|
981
|
+
}
|
|
982
|
+
popDownloadAuthorizationExpiry() {
|
|
983
|
+
const heap = this.downloadAuthorizationExpiryHeap;
|
|
984
|
+
const root = heap[0];
|
|
985
|
+
const last = heap.pop();
|
|
986
|
+
if (root === void 0 || last === void 0 || heap.length === 0) return root;
|
|
987
|
+
let idx = 0;
|
|
988
|
+
while (true) {
|
|
989
|
+
const leftIdx = idx * 2 + 1;
|
|
990
|
+
const rightIdx = leftIdx + 1;
|
|
991
|
+
const left = heap[leftIdx];
|
|
992
|
+
const right = heap[rightIdx];
|
|
993
|
+
if (left === void 0) break;
|
|
994
|
+
const childIdx = right !== void 0 && right.expiresAt < left.expiresAt ? rightIdx : leftIdx;
|
|
995
|
+
const child = heap[childIdx];
|
|
996
|
+
if (child.expiresAt >= last.expiresAt) break;
|
|
997
|
+
heap[idx] = child;
|
|
998
|
+
idx = childIdx;
|
|
999
|
+
}
|
|
1000
|
+
heap[idx] = last;
|
|
1001
|
+
return root;
|
|
1002
|
+
}
|
|
1003
|
+
authorizeDownloadResponseOverrides(downloadAuth, url) {
|
|
1004
|
+
for (const param of DOWNLOAD_RESPONSE_OVERRIDE_PARAMS) {
|
|
1005
|
+
const expected = downloadAuth.responseHeaderOverrides[param];
|
|
1006
|
+
if (expected === void 0) continue;
|
|
1007
|
+
const actual = url.searchParams.get(param);
|
|
1008
|
+
if (actual === expected) continue;
|
|
1009
|
+
const expectedText = JSON.stringify(expected);
|
|
1010
|
+
const actualText = actual === null ? "absent" : JSON.stringify(actual);
|
|
1011
|
+
return this.error(403, "unauthorized", `download authorization requires ${param}=${expectedText}; got ${actualText}`);
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
downloadAuthorizationFromRequest(headers, url, options = {}) {
|
|
1016
|
+
const headerToken = requestHeaderValue(headers, "authorization");
|
|
1017
|
+
if (headerToken !== void 0) return headerToken;
|
|
1018
|
+
if (options.allowQueryAuthorization !== true) return void 0;
|
|
1019
|
+
const queryToken = url.searchParams.get("Authorization") ?? url.searchParams.get("authorization");
|
|
1020
|
+
return queryToken?.startsWith(DOWNLOAD_AUTH_TOKEN_PREFIX) ? queryToken : void 0;
|
|
1021
|
+
}
|
|
1022
|
+
requestHasCapability(authToken, capability) {
|
|
1023
|
+
if (!this.strictAuth) return true;
|
|
1024
|
+
if (authToken === void 0) return false;
|
|
1025
|
+
return this.issuedTokens.get(authToken)?.capabilities.includes(capability) === true;
|
|
1026
|
+
}
|
|
1027
|
+
requireFileLockEnabled(bucket) {
|
|
1028
|
+
if (bucket.info.fileLockConfiguration.value?.isFileLockEnabled === true) return null;
|
|
1029
|
+
return this.error(400, "file_lock_not_enabled", "Bucket does not have file lock enabled");
|
|
1030
|
+
}
|
|
1031
|
+
requireGovernanceBypass(req, authToken, message) {
|
|
1032
|
+
if (req.bypassGovernance !== true) return this.error(400, "file_lock_governance_protected", message);
|
|
1033
|
+
if (!this.requestHasCapability(authToken, Capability.BypassGovernance)) return this.error(403, "unauthorized", `application key lacks required capabilities: ${Capability.BypassGovernance}`);
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
validateReplicationApplicationKey(applicationKeyId, requiredCapabilities, options) {
|
|
1037
|
+
const key = this.keys.get(applicationKeyId);
|
|
1038
|
+
if (key === void 0) return this.invalidReplicationApplicationKey(options.role);
|
|
1039
|
+
if (missingStoredKeyCapabilities(key, requiredCapabilities).length > 0) return this.invalidReplicationApplicationKey(options.role);
|
|
1040
|
+
if (options.bucketId !== void 0 && !storedKeyAllowsBucket(key, options.bucketId)) return this.invalidReplicationApplicationKey(options.role);
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
invalidReplicationApplicationKey(role) {
|
|
1044
|
+
return this.error(400, "bad_request", `${role} application key is invalid or not authorized for this replication configuration`);
|
|
1045
|
+
}
|
|
1046
|
+
invalidReplicationDestinationBucket() {
|
|
1047
|
+
return this.error(400, "bad_request", "replication destination bucket is invalid or not configured for this source application key");
|
|
1048
|
+
}
|
|
1049
|
+
validateReplicationDestinationBuckets(sourceApplicationKeyId, rules) {
|
|
1050
|
+
for (const rule of rules) {
|
|
1051
|
+
const destinationBucketId = requestRecord(rule)?.["destinationBucketId"];
|
|
1052
|
+
if (typeof destinationBucketId !== "string") continue;
|
|
1053
|
+
const destinationApplicationKeyId = this.buckets.get(destinationBucketId)?.info.replicationConfiguration.asReplicationDestination?.sourceToDestinationKeyMapping[sourceApplicationKeyId];
|
|
1054
|
+
if (destinationApplicationKeyId === void 0) return this.invalidReplicationDestinationBucket();
|
|
1055
|
+
const destinationKeyError = this.validateReplicationApplicationKey(destinationApplicationKeyId, [Capability.WriteFiles], {
|
|
1056
|
+
bucketId: destinationBucketId,
|
|
1057
|
+
role: "replication destination"
|
|
1058
|
+
});
|
|
1059
|
+
if (destinationKeyError !== null) return destinationKeyError;
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
validateReplicationApplicationKeys(config, bucketId) {
|
|
1064
|
+
if (!this.strictAuth || config === void 0) return null;
|
|
1065
|
+
const replicationConfiguration = requestRecord(config);
|
|
1066
|
+
const source = requestRecord(replicationConfiguration?.["asReplicationSource"]);
|
|
1067
|
+
if (source !== null) {
|
|
1068
|
+
const sourceApplicationKeyId = source["sourceApplicationKeyId"];
|
|
1069
|
+
if (typeof sourceApplicationKeyId === "string") {
|
|
1070
|
+
const sourceKeyError = this.validateReplicationApplicationKey(sourceApplicationKeyId, [Capability.ReadFiles, Capability.ListFiles], {
|
|
1071
|
+
bucketId,
|
|
1072
|
+
role: "replication source"
|
|
1073
|
+
});
|
|
1074
|
+
if (sourceKeyError !== null) return sourceKeyError;
|
|
1075
|
+
}
|
|
1076
|
+
const rules = source["replicationRules"];
|
|
1077
|
+
if (typeof sourceApplicationKeyId === "string" && Array.isArray(rules)) {
|
|
1078
|
+
const destinationError = this.validateReplicationDestinationBuckets(sourceApplicationKeyId, rules);
|
|
1079
|
+
if (destinationError !== null) return destinationError;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const mapping = requestRecord(requestRecord(replicationConfiguration?.["asReplicationDestination"])?.["sourceToDestinationKeyMapping"]);
|
|
1083
|
+
if (mapping !== null) for (const [sourceApplicationKeyId, destinationApplicationKeyId] of Object.entries(mapping)) {
|
|
1084
|
+
const sourceKeyError = this.validateReplicationApplicationKey(sourceApplicationKeyId, [Capability.ReadFiles, Capability.ListFiles], { role: "replication source" });
|
|
1085
|
+
if (sourceKeyError !== null) return sourceKeyError;
|
|
1086
|
+
if (typeof destinationApplicationKeyId === "string") {
|
|
1087
|
+
const destinationKeyError = this.validateReplicationApplicationKey(destinationApplicationKeyId, [Capability.WriteFiles], {
|
|
1088
|
+
bucketId,
|
|
1089
|
+
role: "replication destination"
|
|
1090
|
+
});
|
|
1091
|
+
if (destinationKeyError !== null) return destinationKeyError;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
requestScope(endpoint, body) {
|
|
1097
|
+
const directBucketId = requestStringField(body, "bucketId");
|
|
1098
|
+
const directFileName = requestStringField(body, "fileName");
|
|
1099
|
+
switch (endpoint) {
|
|
1100
|
+
case "b2_create_bucket": return this.createBucketScope(body);
|
|
1101
|
+
case "b2_list_buckets": return this.listBucketsScope(body, directBucketId);
|
|
1102
|
+
case "b2_list_file_names":
|
|
1103
|
+
case "b2_list_file_versions": return {
|
|
1104
|
+
bucketIds: directBucketId === void 0 ? [] : [directBucketId],
|
|
1105
|
+
fileNames: [requestStringField(body, "prefix") ?? ""],
|
|
1106
|
+
requiresBucketScope: true
|
|
1107
|
+
};
|
|
1108
|
+
case "b2_get_file_info": return this.fileIdScope(requestStringField(body, "fileId")) ?? {
|
|
1109
|
+
bucketIds: [],
|
|
1110
|
+
requiresBucketScope: true
|
|
1111
|
+
};
|
|
1112
|
+
case "b2_delete_file_version":
|
|
1113
|
+
case "b2_update_file_retention":
|
|
1114
|
+
case "b2_update_file_legal_hold": return this.fileVersionScope(body, directFileName);
|
|
1115
|
+
case "b2_get_upload_part_url":
|
|
1116
|
+
case "b2_finish_large_file":
|
|
1117
|
+
case "b2_cancel_large_file":
|
|
1118
|
+
case "b2_list_parts": return this.largeFileScope(requestStringField(body, "fileId")) ?? {
|
|
1119
|
+
bucketIds: [],
|
|
1120
|
+
requiresBucketScope: true
|
|
1121
|
+
};
|
|
1122
|
+
case "b2_copy_file": return this.copyFileScope(body, directFileName);
|
|
1123
|
+
case "b2_copy_part": return this.copyPartScope(body);
|
|
1124
|
+
case "b2_list_unfinished_large_files": return {
|
|
1125
|
+
bucketIds: directBucketId === void 0 ? [] : [directBucketId],
|
|
1126
|
+
fileNames: [requestStringField(body, "namePrefix") ?? ""],
|
|
1127
|
+
requiresBucketScope: true
|
|
1128
|
+
};
|
|
1129
|
+
case "b2_get_download_authorization": return {
|
|
1130
|
+
bucketIds: directBucketId === void 0 ? [] : [directBucketId],
|
|
1131
|
+
fileNames: [requestStringField(body, "fileNamePrefix") ?? ""],
|
|
1132
|
+
requiresBucketScope: true
|
|
1133
|
+
};
|
|
1134
|
+
case "b2_get_bucket_notification_rules": return this.notificationRulesScope(body, directBucketId, false);
|
|
1135
|
+
case "b2_set_bucket_notification_rules": return this.notificationRulesScope(body, directBucketId, true);
|
|
1136
|
+
case "b2_update_bucket": return this.updateBucketScope(body, directBucketId);
|
|
1137
|
+
default: return this.defaultRequestScope(directBucketId, directFileName);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
createBucketScope(body) {
|
|
1141
|
+
return {
|
|
1142
|
+
bucketIds: replicationDestinationBucketIds(body),
|
|
1143
|
+
requiresBucketScope: true,
|
|
1144
|
+
requiresAccountLevelBucketAccess: true
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
updateBucketScope(body, directBucketId) {
|
|
1148
|
+
return {
|
|
1149
|
+
bucketIds: [...directBucketId === void 0 ? [] : [directBucketId], ...replicationDestinationBucketIds(body)],
|
|
1150
|
+
requiresBucketScope: true
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
listBucketsScope(body, directBucketId) {
|
|
1154
|
+
if (directBucketId !== void 0) return {
|
|
1155
|
+
bucketIds: [directBucketId],
|
|
1156
|
+
requiresBucketScope: true
|
|
1157
|
+
};
|
|
1158
|
+
const bucketName = requestStringField(body, "bucketName");
|
|
1159
|
+
if (bucketName !== void 0) {
|
|
1160
|
+
const bucket = [...this.buckets.values()].find((b) => b.info.bucketName === bucketName);
|
|
1161
|
+
return {
|
|
1162
|
+
bucketIds: bucket === void 0 ? [] : [bucket.info.bucketId],
|
|
1163
|
+
requiresBucketScope: true
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
bucketIds: [],
|
|
1168
|
+
requiresBucketScope: true
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
fileVersionScope(body, directFileName) {
|
|
1172
|
+
const fileScope = this.fileIdScope(requestStringField(body, "fileId"));
|
|
1173
|
+
if (fileScope !== void 0) return fileScope;
|
|
1174
|
+
return {
|
|
1175
|
+
bucketIds: [],
|
|
1176
|
+
...directFileName !== void 0 ? { fileNames: [directFileName] } : {},
|
|
1177
|
+
requiresBucketScope: true
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
copyFileScope(body, directFileName) {
|
|
1181
|
+
const sourceScope = this.fileIdScope(requestStringField(body, "sourceFileId"));
|
|
1182
|
+
const destinationBucketId = requestStringField(body, "destinationBucketId");
|
|
1183
|
+
const bucketIds = [...sourceScope?.bucketIds ?? [], ...destinationBucketId !== void 0 ? [destinationBucketId] : []];
|
|
1184
|
+
const scopedFileNames = fileNames(...sourceScope?.fileNames ?? [], directFileName);
|
|
1185
|
+
return {
|
|
1186
|
+
bucketIds,
|
|
1187
|
+
...scopedFileNames !== void 0 ? { fileNames: scopedFileNames } : {},
|
|
1188
|
+
requiresBucketScope: true
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
copyPartScope(body) {
|
|
1192
|
+
const sourceScope = this.fileIdScope(requestStringField(body, "sourceFileId"));
|
|
1193
|
+
const largeScope = this.largeFileScope(requestStringField(body, "largeFileId"));
|
|
1194
|
+
const scopedFileNames = fileNames(...sourceScope?.fileNames ?? [], ...largeScope?.fileNames ?? []);
|
|
1195
|
+
return {
|
|
1196
|
+
bucketIds: [...sourceScope?.bucketIds ?? [], ...largeScope?.bucketIds ?? []],
|
|
1197
|
+
...scopedFileNames !== void 0 ? { fileNames: scopedFileNames } : {},
|
|
1198
|
+
requiresBucketScope: true
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
notificationRulesScope(body, directBucketId, includeRequestRules) {
|
|
1202
|
+
const existingPrefixes = directBucketId === void 0 ? [] : storedNotificationRulePrefixes(this.notificationRules.get(directBucketId));
|
|
1203
|
+
const requestPrefixes = includeRequestRules ? notificationRulePrefixes(body) ?? [] : [];
|
|
1204
|
+
const prefixes = [...existingPrefixes, ...requestPrefixes];
|
|
1205
|
+
return {
|
|
1206
|
+
bucketIds: directBucketId === void 0 ? [] : [directBucketId],
|
|
1207
|
+
...prefixes.length > 0 ? { fileNames: prefixes } : {},
|
|
1208
|
+
requiresBucketScope: true
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
defaultRequestScope(directBucketId, directFileName) {
|
|
1212
|
+
if (directBucketId !== void 0) return {
|
|
1213
|
+
bucketIds: [directBucketId],
|
|
1214
|
+
...directFileName !== void 0 ? { fileNames: [directFileName] } : {},
|
|
1215
|
+
requiresBucketScope: true
|
|
1216
|
+
};
|
|
1217
|
+
return directFileName !== void 0 ? {
|
|
1218
|
+
bucketIds: [],
|
|
1219
|
+
fileNames: [directFileName],
|
|
1220
|
+
requiresBucketScope: false
|
|
1221
|
+
} : void 0;
|
|
1222
|
+
}
|
|
1223
|
+
fileIdScope(fileId) {
|
|
1224
|
+
if (fileId === void 0) return void 0;
|
|
1225
|
+
const found = this.findFile(fileId);
|
|
1226
|
+
if (found === null) return void 0;
|
|
1227
|
+
return {
|
|
1228
|
+
bucketIds: [found.bucketId],
|
|
1229
|
+
fileNames: [found.stored.fileVersion.fileName],
|
|
1230
|
+
requiresBucketScope: true
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
largeFileScope(fileId) {
|
|
1234
|
+
if (fileId === void 0) return void 0;
|
|
1235
|
+
const large = this.largeFiles.get(fileId);
|
|
1236
|
+
if (large === void 0) return void 0;
|
|
1237
|
+
return {
|
|
1238
|
+
bucketIds: [large.bucketId],
|
|
1239
|
+
fileNames: [large.fileName],
|
|
1240
|
+
requiresBucketScope: true
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
async issueUploadAuthorization(options) {
|
|
1244
|
+
const now = this.now();
|
|
1245
|
+
this.pruneExpiredUploadTokens(now);
|
|
1246
|
+
const endpoint = options.kind === "file" ? "b2_upload_file" : "b2_upload_part";
|
|
1247
|
+
const idParam = options.kind === "file" ? "bucketId" : "fileId";
|
|
1248
|
+
const scopedId = options.kind === "file" ? options.bucketId : options.fileId;
|
|
1249
|
+
const uploadId = this.genId(options.kind === "file" ? "upload_file" : "upload_part");
|
|
1250
|
+
const uploadUrl = new URL(b2Url("http://localhost:0", {
|
|
1251
|
+
version: "v3",
|
|
1252
|
+
endpoint
|
|
1253
|
+
}));
|
|
1254
|
+
uploadUrl.searchParams.set(idParam, scopedId);
|
|
1255
|
+
uploadUrl.searchParams.set("uploadId", uploadId);
|
|
1256
|
+
const sourceToken = options.sourceAuthToken === void 0 ? void 0 : this.issuedTokens.get(options.sourceAuthToken);
|
|
1257
|
+
const token = {
|
|
1258
|
+
kind: options.kind,
|
|
1259
|
+
fileName: options.kind === "part" ? options.fileName : null,
|
|
1260
|
+
uploadUrl: uploadUrl.toString(),
|
|
1261
|
+
namePrefix: this.strictAuth ? sourceToken?.namePrefix ?? null : null,
|
|
1262
|
+
applicationKeyId: sourceToken?.applicationKeyId ?? null,
|
|
1263
|
+
expiresAt: now + this.authTokenTtlMs,
|
|
1264
|
+
cleanupAt: now + this.authTokenTtlMs,
|
|
1265
|
+
invalidated: false
|
|
1266
|
+
};
|
|
1267
|
+
const authorizationToken = await this.encodeUploadAuthorizationToken(token);
|
|
1268
|
+
this.uploadTokens.set(authorizationToken, token);
|
|
1269
|
+
return {
|
|
1270
|
+
uploadUrl: uploadUrl.toString(),
|
|
1271
|
+
authorizationToken
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
async validateUploadAuthorization(kind, uploadUrl, authToken, fileName) {
|
|
1275
|
+
if (authToken === void 0 || authToken === "") return this.error(401, "bad_auth_token", "missing upload Authorization header");
|
|
1276
|
+
const token = await this.uploadTokenState(authToken);
|
|
1277
|
+
if (token === null) {
|
|
1278
|
+
this.pruneExpiredUploadTokens();
|
|
1279
|
+
return this.error(401, "bad_auth_token", "unknown upload authorization token");
|
|
1280
|
+
}
|
|
1281
|
+
const now = this.now();
|
|
1282
|
+
if (now >= token.expiresAt) {
|
|
1283
|
+
if (now >= token.cleanupAt) this.uploadTokens.delete(authToken);
|
|
1284
|
+
else this.uploadTokens.set(authToken, token);
|
|
1285
|
+
this.pruneExpiredUploadTokens(now);
|
|
1286
|
+
return this.error(401, "expired_auth_token", "upload authorization token has expired; get a new upload URL");
|
|
1287
|
+
}
|
|
1288
|
+
if (token.invalidated) {
|
|
1289
|
+
this.uploadTokens.set(authToken, token);
|
|
1290
|
+
this.pruneExpiredUploadTokens(now);
|
|
1291
|
+
return this.error(401, "bad_auth_token", "upload authorization token has been invalidated");
|
|
1292
|
+
}
|
|
1293
|
+
if (token.kind !== kind) {
|
|
1294
|
+
this.pruneExpiredUploadTokens(now);
|
|
1295
|
+
return this.error(401, "bad_auth_token", "upload authorization token type mismatch");
|
|
1296
|
+
}
|
|
1297
|
+
this.pruneExpiredUploadTokens(now);
|
|
1298
|
+
if (!this.uploadUrlMatches(token, uploadUrl)) return this.error(401, "bad_auth_token", "upload authorization token is not valid for this upload URL");
|
|
1299
|
+
const scopedName = kind === "file" ? fileName : token.fileName ?? void 0;
|
|
1300
|
+
if (token.namePrefix !== null && scopedName !== void 0) {
|
|
1301
|
+
if (!scopedName.startsWith(token.namePrefix)) return this.error(403, "unauthorized", `application key is scoped to prefix "${token.namePrefix}"; "${scopedName}" is outside scope`);
|
|
1302
|
+
}
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
uploadUrlMatches(token, uploadUrl) {
|
|
1306
|
+
let expected;
|
|
1307
|
+
let actual;
|
|
1308
|
+
try {
|
|
1309
|
+
expected = new URL(token.uploadUrl);
|
|
1310
|
+
actual = new URL(uploadUrl);
|
|
1311
|
+
} catch {
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
if (actual.origin !== expected.origin || actual.pathname !== expected.pathname) return false;
|
|
1315
|
+
const scopeParam = token.kind === "file" ? "bucketId" : "fileId";
|
|
1316
|
+
return actual.searchParams.get(scopeParam) === expected.searchParams.get(scopeParam) && actual.searchParams.get("uploadId") === expected.searchParams.get("uploadId");
|
|
1317
|
+
}
|
|
1318
|
+
allowedBuckets(bucketIds) {
|
|
1319
|
+
if (bucketIds === void 0 || bucketIds === null) return null;
|
|
1320
|
+
return bucketIds.map((id) => ({
|
|
1321
|
+
id: bucketId(id),
|
|
1322
|
+
name: this.buckets.get(id)?.info.bucketName ?? null
|
|
1323
|
+
}));
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Resolve the application key matching the `Authorization` header on
|
|
1327
|
+
* an `authorize_account` request. The header is in the form
|
|
1328
|
+
* `Basic base64(applicationKeyId:applicationKey)`.
|
|
1329
|
+
*
|
|
1330
|
+
* The simulator has two implicit full-access storage credentials:
|
|
1331
|
+
* the documented `test-key-id:test-key` pair used by SDK tests and
|
|
1332
|
+
* `master-key-id:master-key`. In default permissive mode, any other
|
|
1333
|
+
* well-formed Basic credential whose applicationKeyId is not a stored
|
|
1334
|
+
* key also receives that implicit master grant for backward
|
|
1335
|
+
* compatibility. A credential whose applicationKeyId matches a stored
|
|
1336
|
+
* key must use the stored secret and must not be expired.
|
|
1337
|
+
*
|
|
1338
|
+
* @param authzHeader - Raw HTTP `Authorization` header value.
|
|
1339
|
+
*
|
|
1340
|
+
* @returns The authorizing key's grant scope or the rejection reason.
|
|
1341
|
+
*/
|
|
1342
|
+
findAuthorizationGrant(authzHeader) {
|
|
1343
|
+
const credentials = parseBasicAuthorizationHeader(authzHeader);
|
|
1344
|
+
if (credentials === null) return { kind: "invalid" };
|
|
1345
|
+
const stored = this.keys.get(credentials.applicationKeyId);
|
|
1346
|
+
if (stored !== void 0) {
|
|
1347
|
+
if (!timingSafeStringEqual(stored.applicationKey, credentials.applicationKey)) return { kind: "invalid" };
|
|
1348
|
+
if (this.isStoredKeyExpired(stored)) return { kind: "expired" };
|
|
1349
|
+
return {
|
|
1350
|
+
kind: "ok",
|
|
1351
|
+
grant: {
|
|
1352
|
+
capabilities: stored.capabilities,
|
|
1353
|
+
bucketIds: stored.bucketIds,
|
|
1354
|
+
namePrefix: stored.namePrefix,
|
|
1355
|
+
applicationKeyId: credentials.applicationKeyId,
|
|
1356
|
+
accountId: stored.accountId,
|
|
1357
|
+
expirationTimestamp: stored.expirationTimestamp
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
if (isSimulatorApplicationKeyId(credentials.applicationKeyId)) return { kind: "invalid" };
|
|
1362
|
+
if (isImplicitStorageMasterCredential(credentials) || !this.strictAuth) return {
|
|
1363
|
+
kind: "ok",
|
|
1364
|
+
grant: {
|
|
1365
|
+
capabilities: SIMULATOR_MASTER_CAPABILITIES,
|
|
1366
|
+
bucketIds: null,
|
|
1367
|
+
namePrefix: null,
|
|
1368
|
+
applicationKeyId: null,
|
|
1369
|
+
accountId: this.accountId,
|
|
1370
|
+
expirationTimestamp: null
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
return { kind: "invalid" };
|
|
1374
|
+
}
|
|
1375
|
+
canAuthorizePartner(authzHeader) {
|
|
1376
|
+
const credentials = parseBasicAuthorizationHeader(authzHeader);
|
|
1377
|
+
if (credentials === null) return false;
|
|
1378
|
+
if (isImplicitPartnerMasterCredential(credentials)) return true;
|
|
1379
|
+
const stored = this.keys.get(credentials.applicationKeyId);
|
|
1380
|
+
return stored !== void 0 && stored.accountId === this.accountId && !this.isStoredKeyExpired(stored) && timingSafeStringEqual(stored.applicationKey, credentials.applicationKey);
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Creates an {@link HttpTransport} that routes requests to this simulator.
|
|
1384
|
+
* @returns A transport instance backed by this in-memory simulator.
|
|
1385
|
+
*/
|
|
1386
|
+
transport() {
|
|
1387
|
+
return new SimulatorTransport(this);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Register a synthetic failure to inject on requests whose URL contains
|
|
1391
|
+
* `spec.on`. Use this to exercise retry / backoff / error-handling
|
|
1392
|
+
* paths in tests without hand-rolling a wrapping `HttpTransport`. The
|
|
1393
|
+
* fault is consumed in registration order on each matched request;
|
|
1394
|
+
* once its `count` budget is exhausted it auto-retires.
|
|
1395
|
+
*
|
|
1396
|
+
* Faults are checked BEFORE the simulator's real handlers run, so a
|
|
1397
|
+
* matched request never touches in-memory state — failed uploads
|
|
1398
|
+
* don't create partial parts, failed deletes don't remove anything.
|
|
1399
|
+
*
|
|
1400
|
+
* @param spec - The failure to inject. See {@link FaultSpec}.
|
|
1401
|
+
*
|
|
1402
|
+
* @returns A handle whose `clear()` method removes this specific
|
|
1403
|
+
* fault registration (other faults remain in effect).
|
|
1404
|
+
*
|
|
1405
|
+
* @example
|
|
1406
|
+
* ```ts
|
|
1407
|
+
* // Fail the next 2 b2_upload_part calls with 503, then succeed.
|
|
1408
|
+
* sim.injectFailure({ on: 'b2_upload_part', status: 503, count: 2 })
|
|
1409
|
+
*
|
|
1410
|
+
* // Fail every b2_authorize_account with 401 + Retry-After: 5.
|
|
1411
|
+
* const handle = sim.injectFailure({
|
|
1412
|
+
* on: 'b2_authorize_account',
|
|
1413
|
+
* status: 401,
|
|
1414
|
+
* code: 'expired_auth_token',
|
|
1415
|
+
* retryAfter: 5,
|
|
1416
|
+
* })
|
|
1417
|
+
* // ... later
|
|
1418
|
+
* handle.clear()
|
|
1419
|
+
* ```
|
|
1420
|
+
*/
|
|
1421
|
+
injectFailure(spec) {
|
|
1422
|
+
const id = this.nextFaultId++;
|
|
1423
|
+
const fault = {
|
|
1424
|
+
id,
|
|
1425
|
+
spec,
|
|
1426
|
+
remainingSkip: spec.skip ?? 0,
|
|
1427
|
+
remainingCount: spec.count ?? Number.POSITIVE_INFINITY
|
|
1428
|
+
};
|
|
1429
|
+
this.faults.push(fault);
|
|
1430
|
+
return { clear: () => {
|
|
1431
|
+
const idx = this.faults.findIndex((f) => f.id === id);
|
|
1432
|
+
if (idx !== -1) this.faults.splice(idx, 1);
|
|
1433
|
+
} };
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Remove every registered fault. Equivalent to calling `.clear()` on
|
|
1437
|
+
* every handle returned by {@link injectFailure}, plus a defensive
|
|
1438
|
+
* reset for tests that re-use a simulator across cases.
|
|
1439
|
+
*/
|
|
1440
|
+
clearFaults() {
|
|
1441
|
+
this.faults.length = 0;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Internal: checks the registered faults for a match on the given URL
|
|
1445
|
+
* and consumes one if it should fire. Called from
|
|
1446
|
+
* {@link SimulatorTransport.send} before any real handler runs.
|
|
1447
|
+
*
|
|
1448
|
+
* @param url - The request URL to match against each fault's `on`
|
|
1449
|
+
* substring.
|
|
1450
|
+
*
|
|
1451
|
+
* @returns The fault to apply, or `null` if no fault should fire.
|
|
1452
|
+
*
|
|
1453
|
+
* @internal
|
|
1454
|
+
*/
|
|
1455
|
+
consumeMatchingFault(url) {
|
|
1456
|
+
for (let i = 0; i < this.faults.length; i++) {
|
|
1457
|
+
const fault = this.faults[i];
|
|
1458
|
+
if (!url.includes(fault.spec.on)) continue;
|
|
1459
|
+
if (fault.remainingSkip > 0) {
|
|
1460
|
+
fault.remainingSkip -= 1;
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (fault.remainingCount <= 0) continue;
|
|
1464
|
+
fault.remainingCount -= 1;
|
|
1465
|
+
if (fault.remainingCount <= 0) this.faults.splice(i, 1);
|
|
1466
|
+
return fault.spec;
|
|
1467
|
+
}
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Dispatches a JSON API request to the appropriate handler.
|
|
1472
|
+
* @param method - The HTTP method.
|
|
1473
|
+
* @param origin - The request URL origin used for simulator-issued endpoints.
|
|
1474
|
+
* @param path - The request URL path containing the B2 endpoint name.
|
|
1475
|
+
* @param headers - The HTTP request headers; consulted by the
|
|
1476
|
+
* strict-auth gate to look up the issued auth token.
|
|
1477
|
+
* @param body - The parsed JSON request body.
|
|
1478
|
+
*
|
|
1479
|
+
* @returns An object with HTTP status and JSON response body.
|
|
1480
|
+
*/
|
|
1481
|
+
async handleRequest(method, origin, path, headers, body) {
|
|
1482
|
+
const { endpoint, version } = apiPathParts(path);
|
|
1483
|
+
if (!jsonEndpointAllowsMethod(method, endpoint)) return this.error(405, "method_not_allowed", `${endpoint} does not support ${method.toUpperCase()}`);
|
|
1484
|
+
if (this.strictAuth && !this.partner.isEndpoint(endpoint)) {
|
|
1485
|
+
const authError = this.authorizeRequest(headers["authorization"], endpoint, this.requestScope(endpoint, body));
|
|
1486
|
+
if (authError !== null) return authError;
|
|
1487
|
+
}
|
|
1488
|
+
switch (endpoint) {
|
|
1489
|
+
case "b2_authorize_account":
|
|
1490
|
+
if (version === "v3" && this.partner.isAuthorizeEnabled()) return this.partner.authorize(headers["authorization"], origin);
|
|
1491
|
+
return this.authorize(headers["authorization"], origin);
|
|
1492
|
+
case "b2_create_group_member": return this.partner.createGroupMember(body, headers["authorization"]);
|
|
1493
|
+
case "b2_eject_group_member": return this.partner.ejectGroupMember(body, headers["authorization"]);
|
|
1494
|
+
case "b2_list_groups": return this.partner.listGroups(body, headers["authorization"]);
|
|
1495
|
+
case "b2_list_group_members": return this.partner.listGroupMembers(body, headers["authorization"]);
|
|
1496
|
+
case "b2_reserve_trial_create_account": return this.partner.reserveTrialCreateAccount(body, headers["authorization"]);
|
|
1497
|
+
case "bz_list_computers": return this.partner.listComputers(body, headers["authorization"]);
|
|
1498
|
+
case "bz_delete_computer": return this.partner.deleteComputer(body, headers["authorization"]);
|
|
1499
|
+
case "b2_create_bucket": return this.createBucket(body);
|
|
1500
|
+
case "b2_list_buckets": return this.listBuckets(body);
|
|
1501
|
+
case "b2_delete_bucket": return this.deleteBucket(body);
|
|
1502
|
+
case "b2_update_bucket": return this.updateBucket(body);
|
|
1503
|
+
case "b2_get_upload_url": return await this.getUploadUrl(body, headers["authorization"]);
|
|
1504
|
+
case "b2_list_file_names": return this.listFileNames(body);
|
|
1505
|
+
case "b2_list_file_versions": return this.listFileVersions(body);
|
|
1506
|
+
case "b2_get_file_info": return this.getFileInfo(body);
|
|
1507
|
+
case "b2_hide_file": return this.hideFile(body);
|
|
1508
|
+
case "b2_delete_file_version": return this.deleteFileVersion(body, headers["authorization"]);
|
|
1509
|
+
case "b2_copy_file": return await this.copyFile(body);
|
|
1510
|
+
case "b2_start_large_file": return await this.startLargeFile(body);
|
|
1511
|
+
case "b2_get_upload_part_url": return await this.getUploadPartUrl(body, headers["authorization"]);
|
|
1512
|
+
case "b2_finish_large_file": return this.finishLargeFile(body);
|
|
1513
|
+
case "b2_cancel_large_file": return this.cancelLargeFile(body);
|
|
1514
|
+
case "b2_list_unfinished_large_files": return this.listUnfinishedLargeFiles(body);
|
|
1515
|
+
case "b2_list_parts": return this.listParts(body);
|
|
1516
|
+
case "b2_copy_part": return await this.copyPart(body);
|
|
1517
|
+
case "b2_get_download_authorization": return this.getDownloadAuthorization(body);
|
|
1518
|
+
case "b2_create_key": return this.createKey(body, version, headers["authorization"]);
|
|
1519
|
+
case "b2_list_keys": return this.listKeys(body);
|
|
1520
|
+
case "b2_delete_key": return this.deleteKey(body);
|
|
1521
|
+
case "b2_update_file_retention": return this.updateFileRetention(body, headers["authorization"]);
|
|
1522
|
+
case "b2_update_file_legal_hold": return this.updateFileLegalHold(body);
|
|
1523
|
+
case "b2_get_bucket_notification_rules": return this.getBucketNotificationRules(body);
|
|
1524
|
+
case "b2_set_bucket_notification_rules": return this.setBucketNotificationRules(body);
|
|
1525
|
+
default: return {
|
|
1526
|
+
status: 400,
|
|
1527
|
+
body: {
|
|
1528
|
+
status: 400,
|
|
1529
|
+
code: "bad_request",
|
|
1530
|
+
message: `Unknown endpoint: ${endpoint}`
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Handles file and part upload requests (`b2_upload_file`, `b2_upload_part`).
|
|
1537
|
+
* Dispatches to the appropriate internal handler based on the URL.
|
|
1538
|
+
* @param url - The upload endpoint URL used to determine the upload type.
|
|
1539
|
+
* @param headers - The HTTP headers containing file metadata and authorization.
|
|
1540
|
+
* @param data - The raw file or part content as bytes.
|
|
1541
|
+
*
|
|
1542
|
+
* @returns A promise resolving to an object with HTTP status and JSON response body.
|
|
1543
|
+
*/
|
|
1544
|
+
async handleUpload(url, headers, data) {
|
|
1545
|
+
const endpoint = new URL(url).pathname.split("/").pop() ?? "";
|
|
1546
|
+
const kind = endpoint === "b2_upload_file" ? "file" : endpoint === "b2_upload_part" ? "part" : null;
|
|
1547
|
+
if (kind === null) return this.error(400, "bad_request", `Unknown upload endpoint: ${endpoint}`);
|
|
1548
|
+
const fileName = kind === "part" || headers["x-bz-file-name"] === void 0 ? void 0 : decodeHeaderValue(headers["x-bz-file-name"]);
|
|
1549
|
+
const authError = await this.validateUploadAuthorization(kind, url, headers["authorization"], fileName);
|
|
1550
|
+
if (authError !== null) return authError;
|
|
1551
|
+
const contentLengthError = this.validateContentLength(headers, data.byteLength);
|
|
1552
|
+
if (contentLengthError !== null) return contentLengthError;
|
|
1553
|
+
if (kind === "part") return await this.handleUploadPart(url, headers, data);
|
|
1554
|
+
return await this.handleUploadFile(url, headers, data);
|
|
1555
|
+
}
|
|
1556
|
+
validateContentLength(headers, actualLength) {
|
|
1557
|
+
const parsed = parseContentLengthHeader(headers);
|
|
1558
|
+
if (parsed.kind === "absent") return null;
|
|
1559
|
+
if (parsed.kind === "error") return this.error(400, "bad_request", parsed.message);
|
|
1560
|
+
if (parsed.expectedLength !== actualLength) return this.error(400, "bad_request", contentLengthMismatchMessage(parsed.expectedLength, actualLength));
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
parseCustomUploadTimestamp(value) {
|
|
1564
|
+
if (value === void 0 || value === null) return { timestamp: null };
|
|
1565
|
+
if (!this.customUploadTimestampsEnabled) return this.error(400, "custom_timestamp_not_allowed", "Custom upload timestamps are not enabled for this account.");
|
|
1566
|
+
const timestamp = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
|
1567
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > this.now()) return this.error(400, "custom_timestamp_invalid", "The request has an invalid custom upload timestamp.");
|
|
1568
|
+
return { timestamp };
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Handles file download requests (`b2_download_file_by_id`, `/file/` by name).
|
|
1572
|
+
* Returns the file data along with B2 response headers.
|
|
1573
|
+
* @param path - The request URL path identifying the file to download.
|
|
1574
|
+
* @param headers - The HTTP request headers for range or authorization.
|
|
1575
|
+
* @param method - The HTTP method; `'HEAD'` suppresses the response body.
|
|
1576
|
+
*
|
|
1577
|
+
* @returns The download response containing file data and B2 headers.
|
|
1578
|
+
*/
|
|
1579
|
+
handleDownload(path, headers, method = "GET") {
|
|
1580
|
+
if (path.includes("b2_download_file_by_id")) {
|
|
1581
|
+
const url = new URL(`http://localhost${path}`);
|
|
1582
|
+
if (this.strictAuth) {
|
|
1583
|
+
const authError = this.authorizeDownloadRequest(this.downloadAuthorizationFromRequest(headers, url, { allowQueryAuthorization: false }), "b2_download_file_by_id", this.fileIdScope(url.searchParams.get("fileId") ?? void 0) ?? {
|
|
1584
|
+
bucketIds: [],
|
|
1585
|
+
requiresBucketScope: true
|
|
1586
|
+
}, url);
|
|
1587
|
+
if (authError !== null) return this.errorAsDownload(authError);
|
|
1588
|
+
}
|
|
1589
|
+
const fileId = url.searchParams.get("fileId") ?? "";
|
|
1590
|
+
return this.finalizeDownload(this.downloadById(fileId, headers, requestHeaderValue(headers, "range")), url, method);
|
|
1591
|
+
}
|
|
1592
|
+
const fileMatch = path.match(/^([^?]+)/)?.[1]?.match(/\/file\/([^/]+)\/(.+)/);
|
|
1593
|
+
if (fileMatch) {
|
|
1594
|
+
const bucketName = decodeURIComponent(fileMatch[1] ?? "");
|
|
1595
|
+
const fileName = decodeURIComponent(fileMatch[2] ?? "");
|
|
1596
|
+
const url = new URL(`http://localhost${path}`);
|
|
1597
|
+
if (this.strictAuth) {
|
|
1598
|
+
const bucket = [...this.buckets.values()].find((b) => b.info.bucketName === bucketName);
|
|
1599
|
+
const authError = this.authorizeDownloadRequest(this.downloadAuthorizationFromRequest(headers, url, { allowQueryAuthorization: true }), "b2_download_file_by_name", {
|
|
1600
|
+
bucketIds: bucket === void 0 ? [] : [bucket.info.bucketId],
|
|
1601
|
+
fileNames: [fileName],
|
|
1602
|
+
requiresBucketScope: true
|
|
1603
|
+
}, url);
|
|
1604
|
+
if (authError !== null) return this.errorAsDownload(authError);
|
|
1605
|
+
}
|
|
1606
|
+
return this.finalizeDownload(this.downloadByName(bucketName, fileName, headers, requestHeaderValue(headers, "range")), url, method);
|
|
1607
|
+
}
|
|
1608
|
+
return {
|
|
1609
|
+
status: 404,
|
|
1610
|
+
headers: {},
|
|
1611
|
+
data: null
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Convert a synthetic JSON error body into a `SimulatorDownloadResponse`
|
|
1616
|
+
* shape so the strict-auth gate on download paths can surface 401/403s
|
|
1617
|
+
* through the same transport plumbing as a regular failed download.
|
|
1618
|
+
*
|
|
1619
|
+
* @param json - The JSON error from {@link authorizeRequest}.
|
|
1620
|
+
*
|
|
1621
|
+
* @returns A download-shaped response with the error body bytes inline.
|
|
1622
|
+
*/
|
|
1623
|
+
errorAsDownload(json) {
|
|
1624
|
+
return {
|
|
1625
|
+
status: json.status,
|
|
1626
|
+
headers: { "Content-Type": "application/json" },
|
|
1627
|
+
data: utf8Encoder.encode(JSON.stringify(json.body))
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Applies HEAD-method body suppression and `b2Content*` response-header
|
|
1632
|
+
* overrides parsed from the download URL's query string. Mirrors what the
|
|
1633
|
+
* real B2 service does: any `b2Content*` query parameter is echoed back as
|
|
1634
|
+
* the corresponding response header.
|
|
1635
|
+
*
|
|
1636
|
+
* @param response - The download response produced by {@link downloadById} or {@link downloadByName}.
|
|
1637
|
+
* @param url - The parsed download URL (used to read `b2Content*` query params).
|
|
1638
|
+
* @param method - The HTTP method of the originating request.
|
|
1639
|
+
*
|
|
1640
|
+
* @returns The response with overrides applied.
|
|
1641
|
+
*/
|
|
1642
|
+
finalizeDownload(response, url, method) {
|
|
1643
|
+
const newHeaders = { ...response.headers };
|
|
1644
|
+
for (const [param, header] of Object.entries(DOWNLOAD_RESPONSE_OVERRIDE_HEADERS)) {
|
|
1645
|
+
const value = url.searchParams.get(param);
|
|
1646
|
+
if (value !== null) newHeaders[header] = value;
|
|
1647
|
+
}
|
|
1648
|
+
const data = method === "HEAD" ? null : response.data;
|
|
1649
|
+
return {
|
|
1650
|
+
status: response.status,
|
|
1651
|
+
headers: newHeaders,
|
|
1652
|
+
data
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
async handleUploadFile(url, headers, data) {
|
|
1656
|
+
const bucketId = new URL(url).searchParams.get("bucketId");
|
|
1657
|
+
if (!bucketId) return this.error(400, "bad_request", "Missing bucketId");
|
|
1658
|
+
const bucket = this.buckets.get(bucketId);
|
|
1659
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
1660
|
+
const fileName = decodeHeaderValue(headers["x-bz-file-name"] ?? "");
|
|
1661
|
+
const contentType = headers["content-type"] ?? "application/octet-stream";
|
|
1662
|
+
const fileNameError = validateFileName(fileName);
|
|
1663
|
+
if (fileNameError) return this.error(400, fileNameError.code, fileNameError.message);
|
|
1664
|
+
const fileInfo = parseFileInfoHeaders(headers);
|
|
1665
|
+
const fileInfoError = validateFileInfo(fileInfo);
|
|
1666
|
+
if (fileInfoError) return this.error(400, fileInfoError.code, fileInfoError.message);
|
|
1667
|
+
const resolved = await this.resolveUploadSha1(headers["x-bz-content-sha1"], data);
|
|
1668
|
+
if ("status" in resolved) return resolved;
|
|
1669
|
+
const { sha1: contentSha1, data: storedData } = resolved;
|
|
1670
|
+
const serverSideEncryption = await uploadServerSideEncryption(headers, bucket.info.defaultServerSideEncryption);
|
|
1671
|
+
if ("status" in serverSideEncryption) return serverSideEncryption;
|
|
1672
|
+
const customUploadTimestamp = this.parseCustomUploadTimestamp(requestHeaderValue(headers, "x-bz-custom-upload-timestamp"));
|
|
1673
|
+
if ("status" in customUploadTimestamp) return customUploadTimestamp;
|
|
1674
|
+
const fileVersion = this.makeFileVersion({
|
|
1675
|
+
bucketId,
|
|
1676
|
+
fileName,
|
|
1677
|
+
contentType,
|
|
1678
|
+
contentLength: storedData.byteLength,
|
|
1679
|
+
contentSha1,
|
|
1680
|
+
fileInfo,
|
|
1681
|
+
action: FileAction.Upload,
|
|
1682
|
+
serverSideEncryption,
|
|
1683
|
+
...this.newReplicationStatusField(bucketId, fileName),
|
|
1684
|
+
...customUploadTimestamp.timestamp !== null ? { uploadTimestamp: customUploadTimestamp.timestamp } : {}
|
|
1685
|
+
});
|
|
1686
|
+
const stored = {
|
|
1687
|
+
fileVersion,
|
|
1688
|
+
data: storedData,
|
|
1689
|
+
serverSideEncryption
|
|
1690
|
+
};
|
|
1691
|
+
const existing = bucket.files.get(fileName);
|
|
1692
|
+
if (existing) existing.push(stored);
|
|
1693
|
+
else bucket.files.set(fileName, [stored]);
|
|
1694
|
+
this.firePostUploadHooks(fileVersion, bucketId, "b2:ObjectCreated:Upload");
|
|
1695
|
+
return {
|
|
1696
|
+
status: 200,
|
|
1697
|
+
body: fileVersion
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Resolve the `X-Bz-Content-Sha1` header into the SHA-1 to store, verifying
|
|
1702
|
+
* it against the uploaded bytes where B2 would. Returns the SHA-1 string to
|
|
1703
|
+
* persist, or a `400 bad_request` response when a verifiable hash does not
|
|
1704
|
+
* match the data received.
|
|
1705
|
+
*
|
|
1706
|
+
* @param header - The raw `X-Bz-Content-Sha1` header value, if any.
|
|
1707
|
+
* @param data - The uploaded bytes.
|
|
1708
|
+
*
|
|
1709
|
+
* @returns The SHA-1 string to store, or an error response on mismatch.
|
|
1710
|
+
*/
|
|
1711
|
+
async resolveUploadSha1(header, data) {
|
|
1712
|
+
const value = header ?? "none";
|
|
1713
|
+
if (value === "none" || value === "do_not_verify") return {
|
|
1714
|
+
sha1: "none",
|
|
1715
|
+
data
|
|
1716
|
+
};
|
|
1717
|
+
if (value.startsWith("unverified:")) return {
|
|
1718
|
+
sha1: value.slice(11).toLowerCase(),
|
|
1719
|
+
data
|
|
1720
|
+
};
|
|
1721
|
+
if (value === "hex_digits_at_end") {
|
|
1722
|
+
if (data.byteLength < 40) return this.error(400, "bad_request", "Sha1 did not match data received");
|
|
1723
|
+
const body = data.subarray(0, data.byteLength - 40);
|
|
1724
|
+
const trailer = utf8Decoder.decode(data.subarray(data.byteLength - 40)).toLowerCase();
|
|
1725
|
+
const actual = await sha1Hex(body);
|
|
1726
|
+
if (actual !== trailer) return this.error(400, "bad_request", "Sha1 did not match data received");
|
|
1727
|
+
return {
|
|
1728
|
+
sha1: actual,
|
|
1729
|
+
data: body
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
const expected = value.toLowerCase();
|
|
1733
|
+
if (await sha1Hex(data) !== expected) return this.error(400, "bad_request", "Sha1 did not match data received");
|
|
1734
|
+
return {
|
|
1735
|
+
sha1: expected,
|
|
1736
|
+
data
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
async validateUploadPartEncryption(large, headers) {
|
|
1740
|
+
if (large.serverSideEncryption.mode !== EncryptionMode.SseC) {
|
|
1741
|
+
if (!hasCustomerEncryptionHeaders(headers)) return null;
|
|
1742
|
+
return this.error(400, "bad_request", "SSE-C upload part headers require an SSE-C large file");
|
|
1743
|
+
}
|
|
1744
|
+
const supplied = await storedServerSideEncryption({
|
|
1745
|
+
mode: EncryptionMode.SseC,
|
|
1746
|
+
algorithm: headers["x-bz-server-side-encryption-customer-algorithm"],
|
|
1747
|
+
customerKey: headers["x-bz-server-side-encryption-customer-key"],
|
|
1748
|
+
customerKeyMd5: headers["x-bz-server-side-encryption-customer-key-md5"]
|
|
1749
|
+
});
|
|
1750
|
+
if ("status" in supplied) return supplied;
|
|
1751
|
+
if (!sameStoredServerSideEncryption(large.serverSideEncryption, supplied)) return this.error(400, "bad_request", "SSE-C upload parts require matching customer encryption headers");
|
|
1752
|
+
return null;
|
|
1753
|
+
}
|
|
1754
|
+
async handleUploadPart(url, headers, data) {
|
|
1755
|
+
const fileId = new URL(url).searchParams.get("fileId");
|
|
1756
|
+
if (!fileId) return this.error(400, "bad_request", "Missing fileId");
|
|
1757
|
+
const large = this.largeFiles.get(fileId);
|
|
1758
|
+
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
1759
|
+
const partNumber = this.parsePartNumberHeader(requestHeaderValue(headers, "x-bz-part-number"));
|
|
1760
|
+
if (typeof partNumber !== "number") return partNumber;
|
|
1761
|
+
const encryptionError = await this.validateUploadPartEncryption(large, headers);
|
|
1762
|
+
if (encryptionError !== null) return encryptionError;
|
|
1763
|
+
const resolved = await this.resolveUploadSha1(headers["x-bz-content-sha1"], data);
|
|
1764
|
+
if ("status" in resolved) return resolved;
|
|
1765
|
+
const { sha1, data: partData } = resolved;
|
|
1766
|
+
const uploadTimestamp = this.monotonicTimestamp();
|
|
1767
|
+
large.parts.set(partNumber, {
|
|
1768
|
+
data: partData,
|
|
1769
|
+
sha1,
|
|
1770
|
+
contentMd5: null,
|
|
1771
|
+
uploadTimestamp
|
|
1772
|
+
});
|
|
1773
|
+
return {
|
|
1774
|
+
status: 200,
|
|
1775
|
+
body: {
|
|
1776
|
+
fileId: large.fileId,
|
|
1777
|
+
partNumber,
|
|
1778
|
+
contentLength: partData.byteLength,
|
|
1779
|
+
contentSha1: sha1,
|
|
1780
|
+
contentMd5: null,
|
|
1781
|
+
serverSideEncryption: publicServerSideEncryption(large.serverSideEncryption),
|
|
1782
|
+
uploadTimestamp
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
parsePartNumberHeader(header) {
|
|
1787
|
+
if (header === void 0) return this.error(400, "bad_request", "X-Bz-Part-Number header is required");
|
|
1788
|
+
if (!/^\d+$/.test(header)) return this.error(400, "bad_request", `X-Bz-Part-Number must be an integer between ${B2_MIN_PART_NUMBER} and ${B2_MAX_PART_NUMBER}; received ${header}`);
|
|
1789
|
+
const partNumber = Number(header);
|
|
1790
|
+
return this.validatePartNumber(partNumber) ?? partNumber;
|
|
1791
|
+
}
|
|
1792
|
+
validatePartNumber(partNumber) {
|
|
1793
|
+
if (Number.isInteger(partNumber) && partNumber >= B2_MIN_PART_NUMBER && partNumber <= B2_MAX_PART_NUMBER) return null;
|
|
1794
|
+
return this.error(400, "bad_request", `partNumber must be an integer between ${B2_MIN_PART_NUMBER} and ${B2_MAX_PART_NUMBER}; received ${String(partNumber)}`);
|
|
1795
|
+
}
|
|
1796
|
+
downloadById(fileId, headers, range) {
|
|
1797
|
+
const found = this.findFile(fileId);
|
|
1798
|
+
if (found === null) return {
|
|
1799
|
+
status: 404,
|
|
1800
|
+
headers: {},
|
|
1801
|
+
data: null
|
|
1802
|
+
};
|
|
1803
|
+
return this.serveFile(found.stored, headers, range);
|
|
1804
|
+
}
|
|
1805
|
+
downloadByName(bucketName, fileName, headers, range) {
|
|
1806
|
+
for (const bucket of this.buckets.values()) {
|
|
1807
|
+
if (bucket.info.bucketName !== bucketName) continue;
|
|
1808
|
+
const versions = bucket.files.get(fileName);
|
|
1809
|
+
if (!versions || versions.length === 0) break;
|
|
1810
|
+
const latest = versions[versions.length - 1];
|
|
1811
|
+
if (!latest || latest.fileVersion.action === FileAction.Hide) return {
|
|
1812
|
+
status: 404,
|
|
1813
|
+
headers: {},
|
|
1814
|
+
data: null
|
|
1815
|
+
};
|
|
1816
|
+
return this.serveFile(latest, headers, range);
|
|
1817
|
+
}
|
|
1818
|
+
return {
|
|
1819
|
+
status: 404,
|
|
1820
|
+
headers: {},
|
|
1821
|
+
data: null
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
validateDownloadEncryption(stored, headers) {
|
|
1825
|
+
const encryption = stored.serverSideEncryption;
|
|
1826
|
+
if (encryption.mode !== EncryptionMode.SseC) return null;
|
|
1827
|
+
const algorithm = headers["x-bz-server-side-encryption-customer-algorithm"];
|
|
1828
|
+
const customerKey = headers["x-bz-server-side-encryption-customer-key"];
|
|
1829
|
+
const customerKeyMd5 = headers["x-bz-server-side-encryption-customer-key-md5"];
|
|
1830
|
+
const customerKeyBytes = customerKey === void 0 ? null : base64ToBytes(customerKey, 32);
|
|
1831
|
+
const normalizedCustomerKeyMd5 = customerKeyMd5 === void 0 ? null : normalizeSizedBase64(customerKeyMd5, 16);
|
|
1832
|
+
if (algorithm !== encryption.algorithm || customerKeyBytes === null || normalizedCustomerKeyMd5 === null || md5Base64Sync(customerKeyBytes) !== normalizedCustomerKeyMd5) return this.errorAsDownload(this.error(400, "bad_request", "SSE-C downloads require matching customer encryption headers"));
|
|
1833
|
+
if (normalizedCustomerKeyMd5 !== encryption.customerKeyMd5 || customerKeyDigest(customerKeyBytes) !== encryption.customerKeyDigest) return this.errorAsDownload(this.error(403, "access_denied", "SSE-C customer encryption key does not match the stored file"));
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1836
|
+
async validateSourceServerSideEncryption(stored, encryption) {
|
|
1837
|
+
const expected = stored.serverSideEncryption;
|
|
1838
|
+
if (expected.mode !== EncryptionMode.SseC) return null;
|
|
1839
|
+
if (encryption?.mode !== EncryptionMode.SseC) return this.error(400, "bad_request", "SSE-C source copies require customer encryption");
|
|
1840
|
+
const supplied = await storedServerSideEncryption(encryption);
|
|
1841
|
+
if ("status" in supplied) return supplied;
|
|
1842
|
+
if (!sameStoredServerSideEncryption(expected, supplied)) return this.error(400, "bad_request", "SSE-C source customer encryption does not match");
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
async resolveCopyDestinationEncryption(destinationBucket, requested) {
|
|
1846
|
+
if (requested !== void 0) return await storedServerSideEncryption(requested);
|
|
1847
|
+
return await storedServerSideEncryption(destinationBucket.info.defaultServerSideEncryption);
|
|
1848
|
+
}
|
|
1849
|
+
async validateCopyPartDestinationEncryption(large, encryption) {
|
|
1850
|
+
if (encryption === void 0) return large.serverSideEncryption.mode === EncryptionMode.SseC ? this.error(400, "bad_request", "SSE-C destination copies require customer encryption") : null;
|
|
1851
|
+
const supplied = await storedServerSideEncryption(encryption);
|
|
1852
|
+
if ("status" in supplied) return supplied;
|
|
1853
|
+
if (!sameStoredServerSideEncryption(large.serverSideEncryption, supplied)) return this.error(400, "bad_request", "Destination server-side encryption does not match");
|
|
1854
|
+
return null;
|
|
1855
|
+
}
|
|
1856
|
+
serveFile(stored, requestHeaders, range) {
|
|
1857
|
+
const encryptionError = this.validateDownloadEncryption(stored, requestHeaders);
|
|
1858
|
+
if (encryptionError !== null) return encryptionError;
|
|
1859
|
+
const fullData = stored.data;
|
|
1860
|
+
let data = fullData;
|
|
1861
|
+
let status = 200;
|
|
1862
|
+
let contentRange = null;
|
|
1863
|
+
if (range) {
|
|
1864
|
+
const parsed = parseRangeHeader(range, fullData.byteLength);
|
|
1865
|
+
if (parsed.kind === "ok") {
|
|
1866
|
+
data = fullData.subarray(parsed.start, parsed.end + 1);
|
|
1867
|
+
status = 206;
|
|
1868
|
+
contentRange = `bytes ${parsed.start}-${parsed.end}/${fullData.byteLength}`;
|
|
1869
|
+
} else if (parsed.kind === "unsatisfiable") return {
|
|
1870
|
+
status: 416,
|
|
1871
|
+
headers: {
|
|
1872
|
+
"Content-Type": stored.fileVersion.contentType,
|
|
1873
|
+
"Content-Range": `bytes */${fullData.byteLength}`
|
|
1874
|
+
},
|
|
1875
|
+
data: /* @__PURE__ */ new Uint8Array(0)
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
const fv = stored.fileVersion;
|
|
1879
|
+
const headers = {
|
|
1880
|
+
"Content-Type": fv.contentType,
|
|
1881
|
+
"Content-Length": String(data.byteLength),
|
|
1882
|
+
"X-Bz-File-Id": fv.fileId,
|
|
1883
|
+
"X-Bz-File-Name": encodeFileName(fv.fileName),
|
|
1884
|
+
"X-Bz-Content-Sha1": fv.contentSha1 ?? "none",
|
|
1885
|
+
"X-Bz-Upload-Timestamp": String(fv.uploadTimestamp)
|
|
1886
|
+
};
|
|
1887
|
+
for (const [key, value] of Object.entries(fv.fileInfo)) headers[`X-Bz-Info-${encodeFileName(key)}`] = encodeFileName(value);
|
|
1888
|
+
if (!("src_last_modified_millis" in fv.fileInfo)) headers["X-Bz-Info-src_last_modified_millis"] = String(fv.uploadTimestamp);
|
|
1889
|
+
const unauthorizedToRead = [];
|
|
1890
|
+
this.addDownloadEncryptionHeaders(headers, stored.serverSideEncryption, requestHeaders, unauthorizedToRead);
|
|
1891
|
+
this.addDownloadObjectLockHeaders(headers, fv, requestHeaders, unauthorizedToRead);
|
|
1892
|
+
if (unauthorizedToRead.length > 0) headers[DownloadHeaderName.ClientUnauthorizedToRead] = unauthorizedToRead.join(",");
|
|
1893
|
+
if (contentRange !== null) headers["Content-Range"] = contentRange;
|
|
1894
|
+
return {
|
|
1895
|
+
status,
|
|
1896
|
+
headers,
|
|
1897
|
+
data
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
addDownloadEncryptionHeaders(headers, encryption, requestHeaders, unauthorizedToRead) {
|
|
1901
|
+
if (encryption.mode === EncryptionMode.None) return;
|
|
1902
|
+
if (!this.requestHasCapability(requestHeaderValue(requestHeaders, "authorization"), Capability.ReadBucketEncryption)) {
|
|
1903
|
+
if (encryption.mode === EncryptionMode.SseB2) unauthorizedToRead.push(DownloadClientUnauthorizedToReadMarker.ServerSideEncryption);
|
|
1904
|
+
else unauthorizedToRead.push(DownloadClientUnauthorizedToReadMarker.ServerSideEncryptionCustomerAlgorithm, DownloadClientUnauthorizedToReadMarker.ServerSideEncryptionCustomerKeyMd5);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (encryption.mode === EncryptionMode.SseB2) {
|
|
1908
|
+
headers[DownloadHeaderName.ServerSideEncryption] = encryption.algorithm;
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
headers[DownloadHeaderName.ServerSideEncryptionCustomerAlgorithm] = encryption.algorithm;
|
|
1912
|
+
headers[DownloadHeaderName.ServerSideEncryptionCustomerKeyMd5] = encryption.customerKeyMd5;
|
|
1913
|
+
}
|
|
1914
|
+
addDownloadObjectLockHeaders(headers, fileVersion, requestHeaders, unauthorizedToRead) {
|
|
1915
|
+
const authToken = requestHeaderValue(requestHeaders, "authorization");
|
|
1916
|
+
const retention = fileVersion.fileRetention.value;
|
|
1917
|
+
if (retention !== null && retention.mode !== null && retention.retainUntilTimestamp !== null) {
|
|
1918
|
+
if (this.requestHasCapability(authToken, Capability.ReadFileRetentions)) {
|
|
1919
|
+
headers[DownloadHeaderName.FileRetentionMode] = retention.mode;
|
|
1920
|
+
headers[DownloadHeaderName.FileRetentionRetainUntilTimestamp] = String(retention.retainUntilTimestamp);
|
|
1921
|
+
} else unauthorizedToRead.push(DownloadClientUnauthorizedToReadMarker.FileRetentionMode, DownloadClientUnauthorizedToReadMarker.FileRetentionRetainUntilTimestamp);
|
|
1922
|
+
}
|
|
1923
|
+
const legalHold = fileVersion.legalHold.value;
|
|
1924
|
+
if (legalHold === null) return;
|
|
1925
|
+
if (this.requestHasCapability(authToken, Capability.ReadFileLegalHolds)) {
|
|
1926
|
+
headers[DownloadHeaderName.FileLegalHold] = legalHold;
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
unauthorizedToRead.push(DownloadClientUnauthorizedToReadMarker.FileLegalHold);
|
|
1930
|
+
}
|
|
1931
|
+
authorize(authzHeader, origin = "http://localhost:0") {
|
|
1932
|
+
const resolution = this.findAuthorizationGrant(authzHeader);
|
|
1933
|
+
if (resolution.kind === "invalid") return this.error(401, "unauthorized", "application key is not valid");
|
|
1934
|
+
if (resolution.kind === "expired") return this.error(401, "unauthorized", "application key is expired");
|
|
1935
|
+
const { grant } = resolution;
|
|
1936
|
+
const allowedBuckets = this.allowedBuckets(grant.bucketIds);
|
|
1937
|
+
const legacyBucketId = singleBucketId(grant.bucketIds);
|
|
1938
|
+
const legacyBucketName = legacyBucketId === null ? null : this.buckets.get(legacyBucketId)?.info.bucketName ?? null;
|
|
1939
|
+
const tokenStr = `sim_auth_token_${this.nextId++}`;
|
|
1940
|
+
this.issuedTokens.set(tokenStr, {
|
|
1941
|
+
accountId: grant.accountId,
|
|
1942
|
+
capabilities: grant.capabilities,
|
|
1943
|
+
bucketIds: grant.bucketIds,
|
|
1944
|
+
namePrefix: grant.namePrefix,
|
|
1945
|
+
expiresAt: this.now() + this.authTokenTtlMs,
|
|
1946
|
+
applicationKeyId: grant.applicationKeyId
|
|
1947
|
+
});
|
|
1948
|
+
return {
|
|
1949
|
+
status: 200,
|
|
1950
|
+
body: {
|
|
1951
|
+
accountId: accountId(grant.accountId),
|
|
1952
|
+
authorizationToken: tokenStr,
|
|
1953
|
+
apiInfo: { storageApi: {
|
|
1954
|
+
absoluteMinimumPartSize: this.minimumPartSize,
|
|
1955
|
+
apiUrl: origin,
|
|
1956
|
+
bucketId: legacyBucketId === null ? null : bucketId(legacyBucketId),
|
|
1957
|
+
bucketName: legacyBucketName,
|
|
1958
|
+
downloadUrl: origin,
|
|
1959
|
+
infoType: "storageApi",
|
|
1960
|
+
namePrefix: grant.namePrefix,
|
|
1961
|
+
recommendedPartSize: this.recommendedPartSize,
|
|
1962
|
+
s3ApiUrl: origin,
|
|
1963
|
+
allowed: {
|
|
1964
|
+
capabilities: cloneCapabilities(grant.capabilities),
|
|
1965
|
+
buckets: allowedBuckets,
|
|
1966
|
+
bucketId: legacyBucketId === null ? null : bucketId(legacyBucketId),
|
|
1967
|
+
bucketName: legacyBucketName,
|
|
1968
|
+
namePrefix: grant.namePrefix
|
|
1969
|
+
}
|
|
1970
|
+
} },
|
|
1971
|
+
applicationKeyExpirationTimestamp: grant.expirationTimestamp
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
createBucket(req) {
|
|
1976
|
+
const nameError = validateBucketName(req.bucketName);
|
|
1977
|
+
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
1978
|
+
const objectLockEnabled = nextObjectLockEnabled(false, req.fileLockEnabled);
|
|
1979
|
+
if (typeof objectLockEnabled !== "boolean") return this.error(400, objectLockEnabled.code, objectLockEnabled.message);
|
|
1980
|
+
const configError = validateBucketConfigurationFields(req, { objectLockEnabled });
|
|
1981
|
+
if (configError) return this.error(400, configError.code, configError.message);
|
|
1982
|
+
const replicationKeyError = this.validateReplicationApplicationKeys(req.replicationConfiguration, void 0);
|
|
1983
|
+
if (replicationKeyError !== null) return replicationKeyError;
|
|
1984
|
+
for (const b of this.buckets.values()) if (b.info.bucketName === req.bucketName) return this.error(400, "duplicate_bucket_name", "Bucket name already in use");
|
|
1985
|
+
const bid = bucketId(this.genId("b2_bucket"));
|
|
1986
|
+
const defaultRetention = req.defaultRetention ?? {
|
|
1987
|
+
mode: BucketRetentionMode.None,
|
|
1988
|
+
period: null
|
|
1989
|
+
};
|
|
1990
|
+
const info = {
|
|
1991
|
+
accountId: accountId(req.accountId),
|
|
1992
|
+
bucketId: bid,
|
|
1993
|
+
bucketName: req.bucketName,
|
|
1994
|
+
bucketType: req.bucketType,
|
|
1995
|
+
bucketInfo: req.bucketInfo ?? {},
|
|
1996
|
+
corsRules: req.corsRules ?? [],
|
|
1997
|
+
defaultServerSideEncryption: req.defaultServerSideEncryption ?? { mode: EncryptionMode.None },
|
|
1998
|
+
fileLockConfiguration: {
|
|
1999
|
+
isClientAuthorizedToRead: true,
|
|
2000
|
+
value: {
|
|
2001
|
+
isFileLockEnabled: objectLockEnabled,
|
|
2002
|
+
defaultRetention
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
2005
|
+
lifecycleRules: req.lifecycleRules ?? [],
|
|
2006
|
+
options: [],
|
|
2007
|
+
revision: 1,
|
|
2008
|
+
defaultRetention,
|
|
2009
|
+
replicationConfiguration: req.replicationConfiguration === void 0 ? {
|
|
2010
|
+
asReplicationSource: null,
|
|
2011
|
+
asReplicationDestination: null
|
|
2012
|
+
} : normalizeReplicationConfiguration(req.replicationConfiguration)
|
|
2013
|
+
};
|
|
2014
|
+
this.buckets.set(bid, {
|
|
2015
|
+
info,
|
|
2016
|
+
files: /* @__PURE__ */ new Map()
|
|
2017
|
+
});
|
|
2018
|
+
return {
|
|
2019
|
+
status: 200,
|
|
2020
|
+
body: info
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
listBuckets(req) {
|
|
2024
|
+
const bucketTypesError = validateBucketTypes(req.bucketTypes);
|
|
2025
|
+
if (bucketTypesError) return this.error(400, bucketTypesError.code, bucketTypesError.message);
|
|
2026
|
+
return {
|
|
2027
|
+
status: 200,
|
|
2028
|
+
body: { buckets: [...this.buckets.values()].map((b) => b.info).filter((bucket) => req.bucketId === void 0 || bucket.bucketId === req.bucketId).filter((bucket) => req.bucketName === void 0 || bucket.bucketName === req.bucketName).filter((bucket) => req.bucketTypes === void 0 || req.bucketTypes.includes(bucket.bucketType)) }
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
deleteBucket(req) {
|
|
2032
|
+
const bucket = this.buckets.get(req.bucketId);
|
|
2033
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2034
|
+
if (this.bucketHasContents(bucket)) return this.error(400, "cannot_delete_non_empty_bucket", "Bucket is not empty and cannot be deleted");
|
|
2035
|
+
this.buckets.delete(req.bucketId);
|
|
2036
|
+
return {
|
|
2037
|
+
status: 200,
|
|
2038
|
+
body: bucket.info
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
bucketHasContents(bucket) {
|
|
2042
|
+
for (const versions of bucket.files.values()) if (versions.length > 0) return true;
|
|
2043
|
+
for (const large of this.largeFiles.values()) if (large.bucketId === bucket.info.bucketId) return true;
|
|
2044
|
+
return false;
|
|
2045
|
+
}
|
|
2046
|
+
updateBucket(req) {
|
|
2047
|
+
const bucket = this.buckets.get(req["bucketId"]);
|
|
2048
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2049
|
+
const revisionGuard = req["ifRevisionIs"];
|
|
2050
|
+
if (revisionGuard !== void 0) {
|
|
2051
|
+
if (typeof revisionGuard !== "number" || !Number.isInteger(revisionGuard)) return this.error(400, "bad_request", "ifRevisionIs must be an integer");
|
|
2052
|
+
if (revisionGuard !== bucket.info.revision) return this.error(409, "conflict", "ifRevisionIs test failed");
|
|
2053
|
+
}
|
|
2054
|
+
const objectLockEnabled = nextObjectLockEnabled(bucket.info.fileLockConfiguration.value?.isFileLockEnabled ?? false, req["fileLockEnabled"]);
|
|
2055
|
+
if (typeof objectLockEnabled !== "boolean") return this.error(400, objectLockEnabled.code, objectLockEnabled.message);
|
|
2056
|
+
const configError = validateBucketConfigurationFields({
|
|
2057
|
+
bucketInfo: req["bucketInfo"],
|
|
2058
|
+
corsRules: req["corsRules"],
|
|
2059
|
+
defaultRetention: req["defaultRetention"],
|
|
2060
|
+
lifecycleRules: req["lifecycleRules"],
|
|
2061
|
+
replicationConfiguration: req["replicationConfiguration"]
|
|
2062
|
+
}, { objectLockEnabled });
|
|
2063
|
+
if (configError) return this.error(400, configError.code, configError.message);
|
|
2064
|
+
const replicationKeyError = this.validateReplicationApplicationKeys(req["replicationConfiguration"], req["bucketId"]);
|
|
2065
|
+
if (replicationKeyError !== null) return replicationKeyError;
|
|
2066
|
+
const updated = {
|
|
2067
|
+
...bucket.info,
|
|
2068
|
+
...req["bucketType"] !== void 0 ? { bucketType: req["bucketType"] } : {},
|
|
2069
|
+
...req["bucketInfo"] !== void 0 ? { bucketInfo: req["bucketInfo"] } : {},
|
|
2070
|
+
...req["lifecycleRules"] !== void 0 ? { lifecycleRules: req["lifecycleRules"] } : {},
|
|
2071
|
+
...req["corsRules"] !== void 0 ? { corsRules: req["corsRules"] } : {},
|
|
2072
|
+
...req["replicationConfiguration"] !== void 0 ? { replicationConfiguration: normalizeReplicationConfiguration(req["replicationConfiguration"]) } : {},
|
|
2073
|
+
...req["defaultRetention"] !== void 0 ? {
|
|
2074
|
+
defaultRetention: req["defaultRetention"],
|
|
2075
|
+
fileLockConfiguration: {
|
|
2076
|
+
isClientAuthorizedToRead: true,
|
|
2077
|
+
value: {
|
|
2078
|
+
isFileLockEnabled: objectLockEnabled,
|
|
2079
|
+
defaultRetention: req["defaultRetention"]
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
} : {},
|
|
2083
|
+
...req["fileLockEnabled"] !== void 0 && req["defaultRetention"] === void 0 ? { fileLockConfiguration: {
|
|
2084
|
+
isClientAuthorizedToRead: true,
|
|
2085
|
+
value: {
|
|
2086
|
+
isFileLockEnabled: objectLockEnabled,
|
|
2087
|
+
defaultRetention: bucket.info.defaultRetention
|
|
2088
|
+
}
|
|
2089
|
+
} } : {},
|
|
2090
|
+
...req["defaultServerSideEncryption"] !== void 0 ? { defaultServerSideEncryption: req["defaultServerSideEncryption"] } : {},
|
|
2091
|
+
revision: bucket.info.revision + 1
|
|
2092
|
+
};
|
|
2093
|
+
this.buckets.set(req["bucketId"], {
|
|
2094
|
+
info: updated,
|
|
2095
|
+
files: bucket.files
|
|
2096
|
+
});
|
|
2097
|
+
return {
|
|
2098
|
+
status: 200,
|
|
2099
|
+
body: updated
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
async getUploadUrl(req, authToken) {
|
|
2103
|
+
if (!this.buckets.get(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2104
|
+
const uploadAuth = await this.issueUploadAuthorization({
|
|
2105
|
+
kind: "file",
|
|
2106
|
+
sourceAuthToken: authToken,
|
|
2107
|
+
bucketId: req.bucketId
|
|
2108
|
+
});
|
|
2109
|
+
return {
|
|
2110
|
+
status: 200,
|
|
2111
|
+
body: {
|
|
2112
|
+
bucketId: req.bucketId,
|
|
2113
|
+
uploadUrl: uploadAuth.uploadUrl,
|
|
2114
|
+
authorizationToken: uploadAuth.authorizationToken
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
listFileNames(req) {
|
|
2119
|
+
const bucket = this.buckets.get(req.bucketId);
|
|
2120
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2121
|
+
const countError = validateMaxCount(req.maxFileCount, "b2_list_file_names");
|
|
2122
|
+
if (countError) return this.error(400, countError.code, countError.message);
|
|
2123
|
+
const max = req.maxFileCount ?? 1e3;
|
|
2124
|
+
const prefix = req.prefix ?? "";
|
|
2125
|
+
let allFiles = [...bucket.files.entries()].filter(([name]) => name.startsWith(prefix)).map(([_, versions]) => versions[versions.length - 1]).filter((v) => v !== void 0).map((v) => v.fileVersion).sort((a, b) => compareB2FileNames(a.fileName, b.fileName));
|
|
2126
|
+
if (req.startFileName) {
|
|
2127
|
+
const start = req.startFileName;
|
|
2128
|
+
allFiles = allFiles.filter((f) => f.fileName >= start);
|
|
2129
|
+
}
|
|
2130
|
+
return {
|
|
2131
|
+
status: 200,
|
|
2132
|
+
body: {
|
|
2133
|
+
files: allFiles.slice(0, max),
|
|
2134
|
+
nextFileName: allFiles.length > max ? allFiles[max]?.fileName ?? null : null
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
listFileVersions(req) {
|
|
2139
|
+
const bucket = this.buckets.get(req.bucketId);
|
|
2140
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2141
|
+
const countError = validateMaxCount(req.maxFileCount, "b2_list_file_versions");
|
|
2142
|
+
if (countError) return this.error(400, countError.code, countError.message);
|
|
2143
|
+
const max = req.maxFileCount ?? 1e3;
|
|
2144
|
+
const prefix = req.prefix ?? "";
|
|
2145
|
+
const allVersions = [...bucket.files.entries()].filter(([name]) => name.startsWith(prefix)).flatMap(([_, versions]) => versions.map((v) => v.fileVersion)).sort((a, b) => {
|
|
2146
|
+
const nameCmp = compareB2FileNames(a.fileName, b.fileName);
|
|
2147
|
+
if (nameCmp !== 0) return nameCmp;
|
|
2148
|
+
return b.uploadTimestamp - a.uploadTimestamp;
|
|
2149
|
+
});
|
|
2150
|
+
let startIdx = 0;
|
|
2151
|
+
if (req.startFileName !== void 0) {
|
|
2152
|
+
const startName = req.startFileName;
|
|
2153
|
+
const startId = req.startFileId;
|
|
2154
|
+
const nameIdx = allVersions.findIndex((f) => f.fileName >= startName);
|
|
2155
|
+
if (nameIdx === -1) startIdx = allVersions.length;
|
|
2156
|
+
else if (startId !== void 0) {
|
|
2157
|
+
const exactIdx = allVersions.findIndex((f, i) => i >= nameIdx && f.fileId === startId);
|
|
2158
|
+
startIdx = exactIdx !== -1 ? exactIdx : nameIdx;
|
|
2159
|
+
} else startIdx = nameIdx;
|
|
2160
|
+
}
|
|
2161
|
+
const sliced = allVersions.slice(startIdx, startIdx + max);
|
|
2162
|
+
const hasMore = startIdx + max < allVersions.length;
|
|
2163
|
+
return {
|
|
2164
|
+
status: 200,
|
|
2165
|
+
body: {
|
|
2166
|
+
files: sliced,
|
|
2167
|
+
nextFileName: hasMore ? allVersions[startIdx + max]?.fileName ?? null : null,
|
|
2168
|
+
nextFileId: hasMore ? allVersions[startIdx + max]?.fileId ?? null : null
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
getFileInfo(req) {
|
|
2173
|
+
const found = this.findFile(req.fileId);
|
|
2174
|
+
if (found === null) return this.error(404, "file_not_present", "File not found");
|
|
2175
|
+
return {
|
|
2176
|
+
status: 200,
|
|
2177
|
+
body: found.stored.fileVersion
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
hideFile(req) {
|
|
2181
|
+
const bucket = this.buckets.get(req.bucketId);
|
|
2182
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2183
|
+
const nameError = validateFileName(req.fileName);
|
|
2184
|
+
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
2185
|
+
const fileVersion = this.makeFileVersion({
|
|
2186
|
+
bucketId: req.bucketId,
|
|
2187
|
+
fileName: req.fileName,
|
|
2188
|
+
contentType: "application/octet-stream",
|
|
2189
|
+
contentLength: 0,
|
|
2190
|
+
contentSha1: "none",
|
|
2191
|
+
action: FileAction.Hide
|
|
2192
|
+
});
|
|
2193
|
+
const existing = bucket.files.get(req.fileName);
|
|
2194
|
+
const stored = {
|
|
2195
|
+
fileVersion,
|
|
2196
|
+
data: /* @__PURE__ */ new Uint8Array(0),
|
|
2197
|
+
serverSideEncryption: { mode: EncryptionMode.None }
|
|
2198
|
+
};
|
|
2199
|
+
if (existing) existing.push(stored);
|
|
2200
|
+
else bucket.files.set(req.fileName, [stored]);
|
|
2201
|
+
return {
|
|
2202
|
+
status: 200,
|
|
2203
|
+
body: fileVersion
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
deleteFileVersion(req, authToken) {
|
|
2207
|
+
const found = this.findFile(req.fileId);
|
|
2208
|
+
if (found === null || found.stored.fileVersion.fileName !== req.fileName) return this.error(400, "file_not_present", "File version not found");
|
|
2209
|
+
const fv = found.stored.fileVersion;
|
|
2210
|
+
const retention = fv.fileRetention?.value;
|
|
2211
|
+
const legalHold = fv.legalHold?.value;
|
|
2212
|
+
const now = Date.now();
|
|
2213
|
+
if (legalHold === LegalHoldValue.On) return this.error(400, "file_lock_legal_hold_protected", "File is on legal hold and cannot be deleted");
|
|
2214
|
+
if (retention?.mode === RetentionMode.Compliance && retention.retainUntilTimestamp !== null && retention.retainUntilTimestamp > now) return this.error(400, "file_lock_compliance_protected", `File is under compliance-mode retention and cannot be deleted until ${new Date(retention.retainUntilTimestamp).toISOString()}`);
|
|
2215
|
+
if (retention?.mode === RetentionMode.Governance && retention.retainUntilTimestamp !== null && retention.retainUntilTimestamp > now) {
|
|
2216
|
+
const bypassError = this.requireGovernanceBypass(req, authToken, "File is under governance-mode retention; pass bypassGovernance: true to delete");
|
|
2217
|
+
if (bypassError !== null) return bypassError;
|
|
2218
|
+
}
|
|
2219
|
+
found.versions.splice(found.index, 1);
|
|
2220
|
+
if (found.versions.length === 0) found.bucket.files.delete(req.fileName);
|
|
2221
|
+
return {
|
|
2222
|
+
status: 200,
|
|
2223
|
+
body: {
|
|
2224
|
+
fileId: req.fileId,
|
|
2225
|
+
fileName: req.fileName
|
|
2226
|
+
}
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
async copyFile(req) {
|
|
2230
|
+
const nameError = validateFileName(req.fileName);
|
|
2231
|
+
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
2232
|
+
const found = this.findFile(req.sourceFileId);
|
|
2233
|
+
if (found === null) return this.error(404, "file_not_present", "Source file not found");
|
|
2234
|
+
const sourceStored = found.stored;
|
|
2235
|
+
const destBucketId = req.destinationBucketId ?? found.bucketId;
|
|
2236
|
+
const destBucket = this.buckets.get(destBucketId);
|
|
2237
|
+
if (!destBucket) return this.error(400, "bad_bucket_id", "Destination bucket not found");
|
|
2238
|
+
const sourceEncryptionError = await this.validateSourceServerSideEncryption(sourceStored, req.sourceServerSideEncryption);
|
|
2239
|
+
if (sourceEncryptionError !== null) return sourceEncryptionError;
|
|
2240
|
+
const destinationEncryption = await this.resolveCopyDestinationEncryption(destBucket, req.destinationServerSideEncryption);
|
|
2241
|
+
if ("status" in destinationEncryption) return destinationEncryption;
|
|
2242
|
+
let data = sourceStored.data;
|
|
2243
|
+
let contentSha1 = sourceStored.fileVersion.contentSha1 ?? "none";
|
|
2244
|
+
if (req.range !== void 0) {
|
|
2245
|
+
const parsed = parseRangeHeader(req.range, sourceStored.data.byteLength);
|
|
2246
|
+
if (parsed.kind === "malformed") return this.error(400, "bad_request", `Malformed copy range: ${req.range}`);
|
|
2247
|
+
if (parsed.kind === "unsatisfiable") return this.error(416, "range_not_satisfiable", `Unsatisfiable copy range: ${req.range}`);
|
|
2248
|
+
data = sourceStored.data.subarray(parsed.start, parsed.end + 1);
|
|
2249
|
+
contentSha1 = await sha1Hex(data);
|
|
2250
|
+
}
|
|
2251
|
+
const directive = req.metadataDirective;
|
|
2252
|
+
if (directive !== void 0 && directive !== "COPY" && directive !== "REPLACE") return this.error(400, "bad_request", `Invalid metadataDirective: ${directive}`);
|
|
2253
|
+
let contentType;
|
|
2254
|
+
let fileInfo;
|
|
2255
|
+
if (directive === "REPLACE") {
|
|
2256
|
+
if (req.contentType === void 0) return this.error(400, "bad_request", "contentType is required when metadataDirective is REPLACE");
|
|
2257
|
+
const replaceFileInfo = req.fileInfo ?? {};
|
|
2258
|
+
const fileInfoError = validateFileInfo(replaceFileInfo);
|
|
2259
|
+
if (fileInfoError) return this.error(400, fileInfoError.code, fileInfoError.message);
|
|
2260
|
+
contentType = req.contentType;
|
|
2261
|
+
fileInfo = replaceFileInfo;
|
|
2262
|
+
} else {
|
|
2263
|
+
if (req.contentType !== void 0 || req.fileInfo !== void 0) return this.error(400, "bad_request", "contentType and fileInfo may only be set when metadataDirective is REPLACE");
|
|
2264
|
+
contentType = sourceStored.fileVersion.contentType;
|
|
2265
|
+
fileInfo = sourceStored.fileVersion.fileInfo;
|
|
2266
|
+
}
|
|
2267
|
+
const fileVersion = this.makeFileVersion({
|
|
2268
|
+
bucketId: destBucketId,
|
|
2269
|
+
fileName: req.fileName,
|
|
2270
|
+
contentType,
|
|
2271
|
+
contentLength: data.byteLength,
|
|
2272
|
+
contentSha1,
|
|
2273
|
+
fileInfo,
|
|
2274
|
+
action: FileAction.Copy,
|
|
2275
|
+
serverSideEncryption: destinationEncryption,
|
|
2276
|
+
...this.newReplicationStatusField(destBucketId, req.fileName)
|
|
2277
|
+
});
|
|
2278
|
+
const copied = {
|
|
2279
|
+
fileVersion,
|
|
2280
|
+
data: new Uint8Array(data),
|
|
2281
|
+
serverSideEncryption: destinationEncryption
|
|
2282
|
+
};
|
|
2283
|
+
const existing = destBucket.files.get(req.fileName);
|
|
2284
|
+
if (existing) existing.push(copied);
|
|
2285
|
+
else destBucket.files.set(req.fileName, [copied]);
|
|
2286
|
+
this.firePostUploadHooks(fileVersion, destBucketId, "b2:ObjectCreated:Copy");
|
|
2287
|
+
return {
|
|
2288
|
+
status: 200,
|
|
2289
|
+
body: fileVersion
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
async startLargeFile(req) {
|
|
2293
|
+
const bucket = this.buckets.get(req.bucketId);
|
|
2294
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2295
|
+
const nameError = validateFileName(req.fileName);
|
|
2296
|
+
if (nameError) return this.error(400, nameError.code, nameError.message);
|
|
2297
|
+
if (req.fileInfo !== void 0) {
|
|
2298
|
+
const infoError = validateFileInfo(req.fileInfo);
|
|
2299
|
+
if (infoError) return this.error(400, infoError.code, infoError.message);
|
|
2300
|
+
}
|
|
2301
|
+
const customUploadTimestamp = this.parseCustomUploadTimestamp(req.customUploadTimestamp);
|
|
2302
|
+
if ("status" in customUploadTimestamp) return customUploadTimestamp;
|
|
2303
|
+
const fid = this.genId("4_z");
|
|
2304
|
+
const uploadTimestamp = customUploadTimestamp.timestamp ?? this.monotonicTimestamp();
|
|
2305
|
+
const serverSideEncryption = await storedServerSideEncryption(req.serverSideEncryption ?? bucket.info.defaultServerSideEncryption);
|
|
2306
|
+
if ("status" in serverSideEncryption) return serverSideEncryption;
|
|
2307
|
+
const replicationStatus = this.replicationStatusForNewFile(req.bucketId, req.fileName);
|
|
2308
|
+
const large = {
|
|
2309
|
+
fileId: fid,
|
|
2310
|
+
bucketId: req.bucketId,
|
|
2311
|
+
fileName: req.fileName,
|
|
2312
|
+
contentType: req.contentType,
|
|
2313
|
+
fileInfo: req.fileInfo ?? {},
|
|
2314
|
+
fileRetention: req.fileRetention ?? defaultFileRetention(bucket.info.defaultRetention, uploadTimestamp),
|
|
2315
|
+
legalHold: req.legalHold ?? null,
|
|
2316
|
+
...this.replicationStatusMetadataFor(replicationStatus),
|
|
2317
|
+
serverSideEncryption,
|
|
2318
|
+
uploadTimestamp,
|
|
2319
|
+
parts: /* @__PURE__ */ new Map()
|
|
2320
|
+
};
|
|
2321
|
+
this.largeFiles.set(fid, large);
|
|
2322
|
+
return {
|
|
2323
|
+
status: 200,
|
|
2324
|
+
body: {
|
|
2325
|
+
fileId: fid,
|
|
2326
|
+
fileName: req.fileName,
|
|
2327
|
+
accountId: this.accountId,
|
|
2328
|
+
bucketId: req.bucketId,
|
|
2329
|
+
contentType: req.contentType,
|
|
2330
|
+
fileInfo: large.fileInfo,
|
|
2331
|
+
action: FileAction.Start,
|
|
2332
|
+
contentLength: 0,
|
|
2333
|
+
contentSha1: "none",
|
|
2334
|
+
contentMd5: null,
|
|
2335
|
+
fileRetention: {
|
|
2336
|
+
isClientAuthorizedToRead: true,
|
|
2337
|
+
value: large.fileRetention
|
|
2338
|
+
},
|
|
2339
|
+
legalHold: {
|
|
2340
|
+
isClientAuthorizedToRead: true,
|
|
2341
|
+
value: large.legalHold
|
|
2342
|
+
},
|
|
2343
|
+
...this.replicationStatusMetadataFor(large.replicationStatus),
|
|
2344
|
+
serverSideEncryption: publicServerSideEncryption(large.serverSideEncryption),
|
|
2345
|
+
uploadTimestamp: large.uploadTimestamp
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
async getUploadPartUrl(req, authToken) {
|
|
2350
|
+
const large = this.largeFiles.get(req.fileId);
|
|
2351
|
+
if (large === void 0) return this.error(400, "bad_request", "Large file not found");
|
|
2352
|
+
const uploadAuth = await this.issueUploadAuthorization({
|
|
2353
|
+
kind: "part",
|
|
2354
|
+
sourceAuthToken: authToken,
|
|
2355
|
+
fileId: req.fileId,
|
|
2356
|
+
fileName: large.fileName
|
|
2357
|
+
});
|
|
2358
|
+
return {
|
|
2359
|
+
status: 200,
|
|
2360
|
+
body: {
|
|
2361
|
+
fileId: req.fileId,
|
|
2362
|
+
uploadUrl: uploadAuth.uploadUrl,
|
|
2363
|
+
authorizationToken: uploadAuth.authorizationToken
|
|
2364
|
+
}
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
finishLargeFile(req) {
|
|
2368
|
+
const large = this.largeFiles.get(req.fileId);
|
|
2369
|
+
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
2370
|
+
const bucket = this.buckets.get(large.bucketId);
|
|
2371
|
+
if (!bucket) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2372
|
+
const sortedParts = [...large.parts.entries()].sort((a, b) => a[0] - b[0]);
|
|
2373
|
+
if (sortedParts.length > B2_MAX_PART_NUMBER) return this.error(400, "bad_request", `multipart upload has ${sortedParts.length} parts; B2 caps at ${B2_MAX_PART_NUMBER}`);
|
|
2374
|
+
for (const [partNumber] of sortedParts) if (!Number.isInteger(partNumber) || partNumber < B2_MIN_PART_NUMBER || partNumber > B2_MAX_PART_NUMBER) return this.error(400, "bad_request", `partNumber ${partNumber} is outside the [${B2_MIN_PART_NUMBER}, ${B2_MAX_PART_NUMBER}] range B2 accepts`);
|
|
2375
|
+
if (req.partSha1Array.length !== sortedParts.length) return this.error(400, "bad_request", `partSha1Array has ${req.partSha1Array.length} entries but ${sortedParts.length} parts were uploaded`);
|
|
2376
|
+
for (const [i, [partNumber, part]] of sortedParts.entries()) if (req.partSha1Array[i]?.toLowerCase() !== part.sha1) return this.error(400, "bad_request", `part ${partNumber} SHA-1 does not match the uploaded part`);
|
|
2377
|
+
for (const [partNumber, part] of sortedParts.slice(0, -1)) if (part.data.byteLength < this.minimumPartSize) return this.error(400, "bad_request", `part ${partNumber} (${part.data.byteLength} bytes) is smaller than the minimum part size of ${this.minimumPartSize}`);
|
|
2378
|
+
let totalSize = 0;
|
|
2379
|
+
for (const [_, part] of sortedParts) totalSize += part.data.byteLength;
|
|
2380
|
+
const combined = new Uint8Array(totalSize);
|
|
2381
|
+
let offset = 0;
|
|
2382
|
+
for (const [_, part] of sortedParts) {
|
|
2383
|
+
combined.set(part.data, offset);
|
|
2384
|
+
offset += part.data.byteLength;
|
|
2385
|
+
}
|
|
2386
|
+
const fileVersion = this.makeFileVersion({
|
|
2387
|
+
bucketId: large.bucketId,
|
|
2388
|
+
fileName: large.fileName,
|
|
2389
|
+
contentType: large.contentType,
|
|
2390
|
+
contentLength: totalSize,
|
|
2391
|
+
contentSha1: "none",
|
|
2392
|
+
fileInfo: large.fileInfo,
|
|
2393
|
+
action: FileAction.Upload,
|
|
2394
|
+
fileRetention: large.fileRetention,
|
|
2395
|
+
legalHold: large.legalHold,
|
|
2396
|
+
serverSideEncryption: large.serverSideEncryption,
|
|
2397
|
+
uploadTimestamp: large.uploadTimestamp,
|
|
2398
|
+
...this.replicationStatusMetadataFor(large.replicationStatus)
|
|
2399
|
+
});
|
|
2400
|
+
const stored = {
|
|
2401
|
+
fileVersion,
|
|
2402
|
+
data: combined,
|
|
2403
|
+
serverSideEncryption: large.serverSideEncryption
|
|
2404
|
+
};
|
|
2405
|
+
const existing = bucket.files.get(large.fileName);
|
|
2406
|
+
if (existing) existing.push(stored);
|
|
2407
|
+
else bucket.files.set(large.fileName, [stored]);
|
|
2408
|
+
this.largeFiles.delete(req.fileId);
|
|
2409
|
+
this.firePostUploadHooks(fileVersion, large.bucketId, "b2:ObjectCreated:Upload");
|
|
2410
|
+
return {
|
|
2411
|
+
status: 200,
|
|
2412
|
+
body: fileVersion
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
cancelLargeFile(req) {
|
|
2416
|
+
const large = this.largeFiles.get(req.fileId);
|
|
2417
|
+
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
2418
|
+
this.largeFiles.delete(req.fileId);
|
|
2419
|
+
return {
|
|
2420
|
+
status: 200,
|
|
2421
|
+
body: {
|
|
2422
|
+
fileId: large.fileId,
|
|
2423
|
+
accountId: this.accountId,
|
|
2424
|
+
bucketId: large.bucketId,
|
|
2425
|
+
fileName: large.fileName
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
listUnfinishedLargeFiles(req) {
|
|
2430
|
+
const countError = validateMaxCount(req.maxFileCount, "b2_list_unfinished_large_files");
|
|
2431
|
+
if (countError) return this.error(400, countError.code, countError.message);
|
|
2432
|
+
const prefix = req.namePrefix ?? "";
|
|
2433
|
+
const max = req.maxFileCount ?? 100;
|
|
2434
|
+
const candidates = [...this.largeFiles.values()].filter((f) => f.bucketId === req.bucketId).filter((f) => f.fileName.startsWith(prefix)).sort((a, b) => compareB2FileNames(a.fileName, b.fileName));
|
|
2435
|
+
let startIndex = 0;
|
|
2436
|
+
if (req.startFileId !== void 0) {
|
|
2437
|
+
const found = candidates.findIndex((f) => f.fileId === req.startFileId);
|
|
2438
|
+
startIndex = found >= 0 ? found : 0;
|
|
2439
|
+
}
|
|
2440
|
+
return {
|
|
2441
|
+
status: 200,
|
|
2442
|
+
body: {
|
|
2443
|
+
files: candidates.slice(startIndex, startIndex + max).map((f) => ({
|
|
2444
|
+
fileId: f.fileId,
|
|
2445
|
+
fileName: f.fileName,
|
|
2446
|
+
accountId: this.accountId,
|
|
2447
|
+
bucketId: f.bucketId,
|
|
2448
|
+
contentType: f.contentType,
|
|
2449
|
+
action: FileAction.Start,
|
|
2450
|
+
contentLength: 0,
|
|
2451
|
+
contentSha1: "none",
|
|
2452
|
+
contentMd5: null,
|
|
2453
|
+
fileInfo: f.fileInfo,
|
|
2454
|
+
fileRetention: {
|
|
2455
|
+
isClientAuthorizedToRead: true,
|
|
2456
|
+
value: f.fileRetention
|
|
2457
|
+
},
|
|
2458
|
+
legalHold: {
|
|
2459
|
+
isClientAuthorizedToRead: true,
|
|
2460
|
+
value: f.legalHold
|
|
2461
|
+
},
|
|
2462
|
+
...this.replicationStatusMetadataFor(f.replicationStatus),
|
|
2463
|
+
serverSideEncryption: publicServerSideEncryption(f.serverSideEncryption),
|
|
2464
|
+
uploadTimestamp: f.uploadTimestamp
|
|
2465
|
+
})),
|
|
2466
|
+
nextFileId: startIndex + max < candidates.length ? candidates[startIndex + max]?.fileId ?? null : null
|
|
2467
|
+
}
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
listParts(req) {
|
|
2471
|
+
const large = this.largeFiles.get(req.fileId);
|
|
2472
|
+
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
2473
|
+
const countError = validateMaxCount(req.maxPartCount, "b2_list_parts");
|
|
2474
|
+
if (countError) return this.error(400, countError.code, countError.message);
|
|
2475
|
+
const start = req.startPartNumber ?? 1;
|
|
2476
|
+
const max = req.maxPartCount ?? 1e3;
|
|
2477
|
+
const allParts = [...large.parts.entries()].filter(([n]) => n >= start).sort((a, b) => a[0] - b[0]).map(([partNumber, part]) => ({
|
|
2478
|
+
fileId: req.fileId,
|
|
2479
|
+
partNumber,
|
|
2480
|
+
contentLength: part.data.byteLength,
|
|
2481
|
+
contentSha1: part.sha1,
|
|
2482
|
+
contentMd5: part.contentMd5,
|
|
2483
|
+
serverSideEncryption: publicServerSideEncryption(large.serverSideEncryption),
|
|
2484
|
+
uploadTimestamp: part.uploadTimestamp
|
|
2485
|
+
}));
|
|
2486
|
+
return {
|
|
2487
|
+
status: 200,
|
|
2488
|
+
body: {
|
|
2489
|
+
parts: allParts.slice(0, max),
|
|
2490
|
+
nextPartNumber: allParts.length > max ? allParts[max]?.partNumber ?? null : null
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
async copyPart(req) {
|
|
2495
|
+
const large = this.largeFiles.get(req.largeFileId);
|
|
2496
|
+
if (!large) return this.error(400, "bad_request", "Large file not found");
|
|
2497
|
+
const partNumberError = this.validatePartNumber(req.partNumber);
|
|
2498
|
+
if (partNumberError !== null) return partNumberError;
|
|
2499
|
+
const found = this.findFile(req.sourceFileId);
|
|
2500
|
+
if (found === null) return this.error(404, "file_not_present", "Source file not found");
|
|
2501
|
+
const sourceStored = found.stored;
|
|
2502
|
+
const sourceEncryptionError = await this.validateSourceServerSideEncryption(sourceStored, req.sourceServerSideEncryption);
|
|
2503
|
+
if (sourceEncryptionError !== null) return sourceEncryptionError;
|
|
2504
|
+
const destinationEncryptionError = await this.validateCopyPartDestinationEncryption(large, req.destinationServerSideEncryption);
|
|
2505
|
+
if (destinationEncryptionError !== null) return destinationEncryptionError;
|
|
2506
|
+
let partData = sourceStored.data;
|
|
2507
|
+
if (req.range !== void 0) {
|
|
2508
|
+
const parsed = parseRangeHeader(req.range, sourceStored.data.byteLength);
|
|
2509
|
+
if (parsed.kind === "malformed") return this.error(400, "bad_request", `Malformed copy range: ${req.range}`);
|
|
2510
|
+
if (parsed.kind === "unsatisfiable") return this.error(416, "range_not_satisfiable", `Unsatisfiable copy range: ${req.range}`);
|
|
2511
|
+
partData = sourceStored.data.subarray(parsed.start, parsed.end + 1);
|
|
2512
|
+
}
|
|
2513
|
+
const sha1 = await sha1Hex(partData);
|
|
2514
|
+
const uploadTimestamp = this.monotonicTimestamp();
|
|
2515
|
+
large.parts.set(req.partNumber, {
|
|
2516
|
+
data: new Uint8Array(partData),
|
|
2517
|
+
sha1,
|
|
2518
|
+
contentMd5: null,
|
|
2519
|
+
uploadTimestamp
|
|
2520
|
+
});
|
|
2521
|
+
return {
|
|
2522
|
+
status: 200,
|
|
2523
|
+
body: {
|
|
2524
|
+
fileId: req.largeFileId,
|
|
2525
|
+
partNumber: req.partNumber,
|
|
2526
|
+
contentLength: partData.byteLength,
|
|
2527
|
+
contentSha1: sha1,
|
|
2528
|
+
contentMd5: null,
|
|
2529
|
+
serverSideEncryption: publicServerSideEncryption(large.serverSideEncryption),
|
|
2530
|
+
uploadTimestamp
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
getDownloadAuthorization(req) {
|
|
2535
|
+
this.purgeExpiredDownloadAuthorizationTokens();
|
|
2536
|
+
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2537
|
+
const prefixError = validateDownloadAuthorizationPrefix(req.fileNamePrefix);
|
|
2538
|
+
if (prefixError) return this.error(400, prefixError.code, prefixError.message);
|
|
2539
|
+
const durationError = validateDownloadAuthorizationDuration(req.validDurationInSeconds);
|
|
2540
|
+
if (durationError) return this.error(400, durationError.code, durationError.message);
|
|
2541
|
+
const overrideError = validateDownloadResponseOverrides(req);
|
|
2542
|
+
if (overrideError) return this.error(400, overrideError.code, overrideError.message);
|
|
2543
|
+
let authorizationToken = randomDownloadAuthorizationToken();
|
|
2544
|
+
while (this.downloadAuthorizationTokens.has(authorizationToken)) authorizationToken = randomDownloadAuthorizationToken();
|
|
2545
|
+
const expiresAt = this.now() + req.validDurationInSeconds * 1e3;
|
|
2546
|
+
this.downloadAuthorizationTokens.set(authorizationToken, {
|
|
2547
|
+
bucketId: req.bucketId,
|
|
2548
|
+
fileNamePrefix: req.fileNamePrefix,
|
|
2549
|
+
expiresAt,
|
|
2550
|
+
responseHeaderOverrides: pickDownloadResponseOverrides(req)
|
|
2551
|
+
});
|
|
2552
|
+
this.pushDownloadAuthorizationExpiry({
|
|
2553
|
+
token: authorizationToken,
|
|
2554
|
+
expiresAt
|
|
2555
|
+
});
|
|
2556
|
+
return {
|
|
2557
|
+
status: 200,
|
|
2558
|
+
body: {
|
|
2559
|
+
bucketId: req.bucketId,
|
|
2560
|
+
fileNamePrefix: req.fileNamePrefix,
|
|
2561
|
+
authorizationToken
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
createKeyCapabilitiesOutsideGrant(authToken, requested) {
|
|
2566
|
+
const creator = authToken === void 0 ? void 0 : this.issuedTokens.get(authToken);
|
|
2567
|
+
if (creator === void 0) return this.strictAuth ? requested : [];
|
|
2568
|
+
if (creator.applicationKeyId === null) return [];
|
|
2569
|
+
const granted = new Set(creator.capabilities);
|
|
2570
|
+
return requested.filter((capability) => !granted.has(capability));
|
|
2571
|
+
}
|
|
2572
|
+
createKeyCreatorAccountId(authToken) {
|
|
2573
|
+
return (authToken === void 0 ? void 0 : this.issuedTokens.get(authToken))?.accountId ?? this.accountId;
|
|
2574
|
+
}
|
|
2575
|
+
createKey(req, version, authToken) {
|
|
2576
|
+
if (version === "v4" && hasOwnField(req, "bucketId")) return this.error(400, "bad_request", "bucketId is not accepted by v4 b2_create_key; use bucketIds");
|
|
2577
|
+
if (version !== "v4" && req.bucketId !== void 0 && req.bucketIds !== void 0) return this.error(400, "bad_request", "b2_create_key accepts either bucketIds or bucketId");
|
|
2578
|
+
const bucketIds = version !== "v4" && req.bucketId !== void 0 ? Object.freeze([req.bucketId]) : normalizeKeyBucketIds(req);
|
|
2579
|
+
const namePrefix = req.namePrefix === void 0 || req.namePrefix === "" ? null : req.namePrefix;
|
|
2580
|
+
const creatorAccountId = this.createKeyCreatorAccountId(authToken);
|
|
2581
|
+
if (req.accountId !== creatorAccountId) return this.error(400, "bad_request", "accountId must match authorized account");
|
|
2582
|
+
const keyNameError = validateCreateKeyName(req.keyName);
|
|
2583
|
+
if (keyNameError) return this.error(400, keyNameError.code, keyNameError.message);
|
|
2584
|
+
const keyName = req.keyName;
|
|
2585
|
+
const capabilitiesError = validateCreateKeyCapabilities(req.capabilities);
|
|
2586
|
+
if (capabilitiesError) return this.error(400, capabilitiesError.code, capabilitiesError.message);
|
|
2587
|
+
const capabilities = normalizeCreateKeyCapabilities(req.capabilities);
|
|
2588
|
+
const unauthorizedCapabilities = this.createKeyCapabilitiesOutsideGrant(authToken, capabilities);
|
|
2589
|
+
if (unauthorizedCapabilities.length > 0) return this.error(400, "bad_request", `requested capabilities exceed creator grant: ${unauthorizedCapabilities.join(", ")}`);
|
|
2590
|
+
if (hasKeyManagementCapability(capabilities) && (bucketIds !== null || namePrefix !== null)) return this.error(400, "bad_request", "key-management capabilities are account-level and cannot be bucket or name-prefix scoped");
|
|
2591
|
+
const kid = this.genId("sim_key");
|
|
2592
|
+
const stored = {
|
|
2593
|
+
applicationKeyId: kid,
|
|
2594
|
+
keyName,
|
|
2595
|
+
capabilities,
|
|
2596
|
+
accountId: creatorAccountId,
|
|
2597
|
+
applicationKey: this.genId("sim_secret"),
|
|
2598
|
+
bucketIds,
|
|
2599
|
+
namePrefix,
|
|
2600
|
+
expirationTimestamp: req.validDurationInSeconds !== void 0 ? this.now() + req.validDurationInSeconds * 1e3 : null
|
|
2601
|
+
};
|
|
2602
|
+
this.keys.set(kid, stored);
|
|
2603
|
+
return {
|
|
2604
|
+
status: 200,
|
|
2605
|
+
body: {
|
|
2606
|
+
keyName: stored.keyName,
|
|
2607
|
+
applicationKeyId: stored.applicationKeyId,
|
|
2608
|
+
applicationKey: stored.applicationKey,
|
|
2609
|
+
capabilities: cloneCapabilities(stored.capabilities),
|
|
2610
|
+
accountId: stored.accountId,
|
|
2611
|
+
expirationTimestamp: stored.expirationTimestamp,
|
|
2612
|
+
bucketIds: cloneBucketIds(stored.bucketIds),
|
|
2613
|
+
bucketId: singleBucketId(stored.bucketIds),
|
|
2614
|
+
namePrefix: stored.namePrefix,
|
|
2615
|
+
options: []
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
listKeys(req) {
|
|
2620
|
+
const countError = validateMaxCount(req.maxKeyCount, "b2_list_keys");
|
|
2621
|
+
if (countError) return this.error(400, countError.code, countError.message);
|
|
2622
|
+
const max = req.maxKeyCount ?? 1e3;
|
|
2623
|
+
let allKeys = [...this.keys.values()].sort((a, b) => a.applicationKeyId.localeCompare(b.applicationKeyId));
|
|
2624
|
+
if (req.startApplicationKeyId) {
|
|
2625
|
+
const start = req.startApplicationKeyId;
|
|
2626
|
+
allKeys = allKeys.filter((k) => k.applicationKeyId >= start);
|
|
2627
|
+
}
|
|
2628
|
+
return {
|
|
2629
|
+
status: 200,
|
|
2630
|
+
body: {
|
|
2631
|
+
keys: allKeys.slice(0, max).map((k) => ({
|
|
2632
|
+
keyName: k.keyName,
|
|
2633
|
+
applicationKeyId: k.applicationKeyId,
|
|
2634
|
+
capabilities: cloneCapabilities(k.capabilities),
|
|
2635
|
+
accountId: k.accountId,
|
|
2636
|
+
expirationTimestamp: k.expirationTimestamp,
|
|
2637
|
+
bucketIds: cloneBucketIds(k.bucketIds),
|
|
2638
|
+
bucketId: singleBucketId(k.bucketIds),
|
|
2639
|
+
namePrefix: k.namePrefix,
|
|
2640
|
+
options: []
|
|
2641
|
+
})),
|
|
2642
|
+
nextApplicationKeyId: allKeys.length > max ? allKeys[max]?.applicationKeyId ?? null : null
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
deleteKey(req) {
|
|
2647
|
+
const key = this.keys.get(req.applicationKeyId);
|
|
2648
|
+
if (!key) return this.error(400, "bad_request", "Key not found");
|
|
2649
|
+
this.keys.delete(req.applicationKeyId);
|
|
2650
|
+
for (const [token, scope] of this.issuedTokens.entries()) if (scope.applicationKeyId === req.applicationKeyId) this.issuedTokens.delete(token);
|
|
2651
|
+
for (const [token, scope] of this.uploadTokens.entries()) if (scope.applicationKeyId === req.applicationKeyId) {
|
|
2652
|
+
scope.invalidated = true;
|
|
2653
|
+
this.uploadTokens.set(token, scope);
|
|
2654
|
+
}
|
|
2655
|
+
return {
|
|
2656
|
+
status: 200,
|
|
2657
|
+
body: {
|
|
2658
|
+
keyName: key.keyName,
|
|
2659
|
+
applicationKeyId: key.applicationKeyId,
|
|
2660
|
+
capabilities: cloneCapabilities(key.capabilities),
|
|
2661
|
+
accountId: key.accountId,
|
|
2662
|
+
expirationTimestamp: key.expirationTimestamp,
|
|
2663
|
+
bucketIds: cloneBucketIds(key.bucketIds),
|
|
2664
|
+
bucketId: singleBucketId(key.bucketIds),
|
|
2665
|
+
namePrefix: key.namePrefix,
|
|
2666
|
+
options: []
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
updateFileRetention(req, authToken) {
|
|
2671
|
+
const found = this.findFile(req.fileId);
|
|
2672
|
+
if (found === null || found.stored.fileVersion.fileName !== req.fileName) return this.error(404, "file_not_present", "File not found");
|
|
2673
|
+
const fileLockError = this.requireFileLockEnabled(found.bucket);
|
|
2674
|
+
if (fileLockError !== null) return fileLockError;
|
|
2675
|
+
const fileRetention = parseFileRetentionValue(req.fileRetention, Date.now());
|
|
2676
|
+
if (fileRetention === null) return this.error(400, "bad_request", "fileRetention must use a valid mode and retainUntilTimestamp pair");
|
|
2677
|
+
const current = found.stored.fileVersion.fileRetention?.value;
|
|
2678
|
+
if (current?.mode === RetentionMode.Compliance && (fileRetention.mode !== RetentionMode.Compliance || isRetentionWeakened(current, fileRetention))) return this.error(400, "file_lock_compliance_protected", "Compliance-mode retention cannot be shortened or removed");
|
|
2679
|
+
if (requiresGovernanceBypass(current, fileRetention)) {
|
|
2680
|
+
const bypassError = this.requireGovernanceBypass(req, authToken, "Governance-mode retention cannot be shortened, removed, or converted without bypassGovernance");
|
|
2681
|
+
if (bypassError !== null) return bypassError;
|
|
2682
|
+
}
|
|
2683
|
+
found.versions[found.index] = {
|
|
2684
|
+
fileVersion: {
|
|
2685
|
+
...found.stored.fileVersion,
|
|
2686
|
+
fileRetention: {
|
|
2687
|
+
isClientAuthorizedToRead: true,
|
|
2688
|
+
value: fileRetention
|
|
2689
|
+
}
|
|
2690
|
+
},
|
|
2691
|
+
data: found.stored.data,
|
|
2692
|
+
serverSideEncryption: found.stored.serverSideEncryption
|
|
2693
|
+
};
|
|
2694
|
+
return {
|
|
2695
|
+
status: 200,
|
|
2696
|
+
body: {
|
|
2697
|
+
fileName: req.fileName,
|
|
2698
|
+
fileId: req.fileId,
|
|
2699
|
+
fileRetention
|
|
2700
|
+
}
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
updateFileLegalHold(req) {
|
|
2704
|
+
const found = this.findFile(req.fileId);
|
|
2705
|
+
if (found === null || found.stored.fileVersion.fileName !== req.fileName) return this.error(404, "file_not_present", "File not found");
|
|
2706
|
+
const fileLockError = this.requireFileLockEnabled(found.bucket);
|
|
2707
|
+
if (fileLockError !== null) return fileLockError;
|
|
2708
|
+
if (req.legalHold !== LegalHoldValue.On && req.legalHold !== LegalHoldValue.Off) return this.error(400, "bad_request", "legalHold must be \"on\" or \"off\"");
|
|
2709
|
+
found.versions[found.index] = {
|
|
2710
|
+
fileVersion: {
|
|
2711
|
+
...found.stored.fileVersion,
|
|
2712
|
+
legalHold: {
|
|
2713
|
+
isClientAuthorizedToRead: true,
|
|
2714
|
+
value: req.legalHold
|
|
2715
|
+
}
|
|
2716
|
+
},
|
|
2717
|
+
data: found.stored.data,
|
|
2718
|
+
serverSideEncryption: found.stored.serverSideEncryption
|
|
2719
|
+
};
|
|
2720
|
+
return {
|
|
2721
|
+
status: 200,
|
|
2722
|
+
body: {
|
|
2723
|
+
fileName: req.fileName,
|
|
2724
|
+
fileId: req.fileId,
|
|
2725
|
+
legalHold: req.legalHold
|
|
2726
|
+
}
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
getBucketNotificationRules(req) {
|
|
2730
|
+
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2731
|
+
const rules = this.notificationRules.get(req.bucketId) ?? [];
|
|
2732
|
+
return {
|
|
2733
|
+
status: 200,
|
|
2734
|
+
body: {
|
|
2735
|
+
bucketId: req.bucketId,
|
|
2736
|
+
eventNotificationRules: rules
|
|
2737
|
+
}
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
setBucketNotificationRules(req) {
|
|
2741
|
+
if (!this.buckets.has(req.bucketId)) return this.error(400, "bad_bucket_id", "Bucket not found");
|
|
2742
|
+
const rulesError = validateNotificationRules(req.eventNotificationRules);
|
|
2743
|
+
if (rulesError) return this.error(400, rulesError.code, rulesError.message);
|
|
2744
|
+
this.notificationRules.set(req.bucketId, req.eventNotificationRules);
|
|
2745
|
+
return {
|
|
2746
|
+
status: 200,
|
|
2747
|
+
body: {
|
|
2748
|
+
bucketId: req.bucketId,
|
|
2749
|
+
eventNotificationRules: req.eventNotificationRules
|
|
2750
|
+
}
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Locates a stored file version by its `fileId`, scanning every bucket.
|
|
2755
|
+
*
|
|
2756
|
+
* Returns enough context to support read-only inspection (`stored`,
|
|
2757
|
+
* `bucketId`) AND in-place mutation (`versions`, `index`) so callers
|
|
2758
|
+
* that need to splice the version out can do so without re-scanning.
|
|
2759
|
+
*
|
|
2760
|
+
* Real B2 fileIds embed the bucketId, so production lookups are O(1);
|
|
2761
|
+
* the simulator's flat ID generator (`this.genId('4_z')`) doesn't, so this
|
|
2762
|
+
* is O(buckets × files × versions). Acceptable for tests.
|
|
2763
|
+
*
|
|
2764
|
+
* @param fileId - The file version ID to locate.
|
|
2765
|
+
*
|
|
2766
|
+
* @returns The location of the matching version, or `null` if not found.
|
|
2767
|
+
*/
|
|
2768
|
+
findFile(fileId) {
|
|
2769
|
+
for (const [bid, bucket] of this.buckets.entries()) for (const versions of bucket.files.values()) {
|
|
2770
|
+
const idx = versions.findIndex((v) => v.fileVersion.fileId === fileId);
|
|
2771
|
+
if (idx !== -1) return {
|
|
2772
|
+
stored: versions[idx],
|
|
2773
|
+
bucketId: bid,
|
|
2774
|
+
bucket,
|
|
2775
|
+
versions,
|
|
2776
|
+
index: idx
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
return null;
|
|
2780
|
+
}
|
|
2781
|
+
makeFileVersion(params) {
|
|
2782
|
+
return {
|
|
2783
|
+
accountId: accountId(this.accountId),
|
|
2784
|
+
action: params.action,
|
|
2785
|
+
bucketId: bucketId(params.bucketId),
|
|
2786
|
+
contentLength: params.contentLength,
|
|
2787
|
+
contentMd5: null,
|
|
2788
|
+
contentSha1: params.contentSha1,
|
|
2789
|
+
contentType: params.contentType,
|
|
2790
|
+
fileId: fileId(this.genId("4_z")),
|
|
2791
|
+
fileInfo: params.fileInfo ?? {},
|
|
2792
|
+
fileName: params.fileName,
|
|
2793
|
+
fileRetention: {
|
|
2794
|
+
isClientAuthorizedToRead: true,
|
|
2795
|
+
value: params.fileRetention ?? null
|
|
2796
|
+
},
|
|
2797
|
+
legalHold: {
|
|
2798
|
+
isClientAuthorizedToRead: true,
|
|
2799
|
+
value: params.legalHold ?? null
|
|
2800
|
+
},
|
|
2801
|
+
...this.replicationStatusMetadataFor(params.replicationStatus),
|
|
2802
|
+
serverSideEncryption: publicServerSideEncryption(params.serverSideEncryption ?? { mode: EncryptionMode.None }),
|
|
2803
|
+
uploadTimestamp: params.uploadTimestamp ?? this.monotonicTimestamp()
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
newReplicationStatusField(bucketId, fileName) {
|
|
2807
|
+
return this.replicationStatusMetadataFor(this.replicationStatusForNewFile(bucketId, fileName));
|
|
2808
|
+
}
|
|
2809
|
+
replicationStatusMetadataFor(replicationStatus) {
|
|
2810
|
+
return replicationStatus === void 0 ? {} : { replicationStatus };
|
|
2811
|
+
}
|
|
2812
|
+
replicationStatusForNewFile(bucketId, fileName) {
|
|
2813
|
+
return this.matchingEnabledReplicationRules(bucketId, fileName).length > 0 ? "PENDING" : void 0;
|
|
2814
|
+
}
|
|
2815
|
+
matchingEnabledReplicationRules(bucketId, fileName) {
|
|
2816
|
+
return (this.buckets.get(bucketId)?.info.replicationConfiguration.asReplicationSource?.replicationRules)?.filter((rule) => rule.isEnabled && fileName.startsWith(rule.fileNamePrefix)) ?? [];
|
|
2817
|
+
}
|
|
2818
|
+
error(status, code, message) {
|
|
2819
|
+
return {
|
|
2820
|
+
status,
|
|
2821
|
+
body: {
|
|
2822
|
+
status,
|
|
2823
|
+
code,
|
|
2824
|
+
message
|
|
2825
|
+
}
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Fire the pluggable post-upload hooks for a file that just landed in
|
|
2830
|
+
* a bucket: matching event-notification rules → `onWebhookDeliver`,
|
|
2831
|
+
* configured replication source rules → `onReplicate`. Errors thrown
|
|
2832
|
+
* from user-supplied hooks are swallowed so a buggy listener never
|
|
2833
|
+
* masks an otherwise-successful API response.
|
|
2834
|
+
*
|
|
2835
|
+
* Fired-and-forgotten (no await) by the handler so the synthetic
|
|
2836
|
+
* response is returned to the caller as fast as production B2 would
|
|
2837
|
+
* acknowledge the write.
|
|
2838
|
+
*
|
|
2839
|
+
* @param fileVersion - The freshly-stored file metadata.
|
|
2840
|
+
* @param bucketId - The bucket the upload landed in.
|
|
2841
|
+
* @param eventType - The B2 event-type tag (e.g. `'b2:ObjectCreated:Upload'`)
|
|
2842
|
+
* used to match against `EventNotificationRule.eventTypes` globs.
|
|
2843
|
+
*/
|
|
2844
|
+
firePostUploadHooks(fileVersion, bucketId, eventType) {
|
|
2845
|
+
if (!this.buckets.get(bucketId)) return;
|
|
2846
|
+
if (this.onWebhookDeliver !== void 0) {
|
|
2847
|
+
const rules = this.notificationRules.get(bucketId) ?? [];
|
|
2848
|
+
for (const rule of rules) {
|
|
2849
|
+
if (!rule.isEnabled) continue;
|
|
2850
|
+
if (!rule.eventTypes.some((pattern) => eventTypeMatches(pattern, eventType))) continue;
|
|
2851
|
+
const hook = this.onWebhookDeliver;
|
|
2852
|
+
this.dispatchHook("webhook", () => hook({
|
|
2853
|
+
rule,
|
|
2854
|
+
fileVersion,
|
|
2855
|
+
bucketId
|
|
2856
|
+
}));
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
if (this.onReplicate !== void 0) for (const rule of this.matchingEnabledReplicationRules(bucketId, fileVersion.fileName)) {
|
|
2860
|
+
const hook = this.onReplicate;
|
|
2861
|
+
this.dispatchHook("replication", () => hook({
|
|
2862
|
+
sourceFileVersion: fileVersion,
|
|
2863
|
+
sourceBucketId: bucketId,
|
|
2864
|
+
destinationBucketId: rule.destinationBucketId
|
|
2865
|
+
}));
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Schedule a user-supplied hook callback. Errors are routed to
|
|
2870
|
+
* `onHookError` if configured, swallowed otherwise. The returned
|
|
2871
|
+
* promise is tracked in {@link pendingHooks} so `flushHooks()` can
|
|
2872
|
+
* wait for every dispatched hook to settle.
|
|
2873
|
+
*
|
|
2874
|
+
* @param kind - Which hook this is (`'webhook'` or `'replication'`).
|
|
2875
|
+
* @param fn - Thunk that performs the hook invocation.
|
|
2876
|
+
*/
|
|
2877
|
+
dispatchHook(kind, fn) {
|
|
2878
|
+
const task = Promise.resolve().then(() => fn()).catch((err) => {
|
|
2879
|
+
if (this.onHookError !== void 0) this.onHookError({
|
|
2880
|
+
kind,
|
|
2881
|
+
error: toError(err)
|
|
2882
|
+
});
|
|
2883
|
+
}).finally(() => {
|
|
2884
|
+
this.pendingHooks.delete(task);
|
|
2885
|
+
});
|
|
2886
|
+
this.pendingHooks.add(task);
|
|
2887
|
+
}
|
|
2888
|
+
};
|
|
2889
|
+
/**
|
|
2890
|
+
* Module-local cache of compiled glob regexes. Avoids recompiling the
|
|
2891
|
+
* same `eventTypes` pattern on every upload's hook dispatch — patterns
|
|
2892
|
+
* are stable for the lifetime of a `setBucketNotificationRules` call
|
|
2893
|
+
* and typically reused across thousands of uploads.
|
|
2894
|
+
*/
|
|
2895
|
+
var eventTypeRegexCache = /* @__PURE__ */ new Map();
|
|
2896
|
+
/**
|
|
2897
|
+
* Wildcard match for B2 event-type globs. Supports the prefix-glob form
|
|
2898
|
+
* the B2 docs document: `b2:ObjectCreated:*` matches
|
|
2899
|
+
* `b2:ObjectCreated:Upload`, `b2:ObjectCreated:Copy`, etc. Exact
|
|
2900
|
+
* matches without `*` match literally.
|
|
2901
|
+
*
|
|
2902
|
+
* @param pattern - Glob from an `EventNotificationRule.eventTypes` entry.
|
|
2903
|
+
* @param eventType - Concrete event type produced by the simulator.
|
|
2904
|
+
*
|
|
2905
|
+
* @returns `true` when the pattern matches the event type.
|
|
2906
|
+
*/
|
|
1434
2907
|
function eventTypeMatches(pattern, eventType) {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
2908
|
+
if (!pattern.includes("*")) return pattern === eventType;
|
|
2909
|
+
let compiled = eventTypeRegexCache.get(pattern);
|
|
2910
|
+
if (compiled === void 0) {
|
|
2911
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
2912
|
+
compiled = new RegExp(`^${escaped}$`);
|
|
2913
|
+
eventTypeRegexCache.set(pattern, compiled);
|
|
2914
|
+
}
|
|
2915
|
+
return compiled.test(eventType);
|
|
1443
2916
|
}
|
|
2917
|
+
/**
|
|
2918
|
+
* Build a synthetic {@link HttpResponse} from a consumed {@link FaultSpec}.
|
|
2919
|
+
* Mirrors the shape of real B2 error responses so the SDK's
|
|
2920
|
+
* `RetryTransport` / `classifyError` paths see realistic input.
|
|
2921
|
+
*
|
|
2922
|
+
* @param fault - The fault spec to render.
|
|
2923
|
+
*
|
|
2924
|
+
* @returns An `HttpResponse` ready to return from `transport.send`.
|
|
2925
|
+
*/
|
|
1444
2926
|
function buildFaultResponse(fault) {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
2927
|
+
const status = fault.status ?? 503;
|
|
2928
|
+
const code = fault.code ?? "service_unavailable";
|
|
2929
|
+
const message = fault.message ?? "simulated failure";
|
|
2930
|
+
const body = JSON.stringify({
|
|
2931
|
+
status,
|
|
2932
|
+
code,
|
|
2933
|
+
message
|
|
2934
|
+
});
|
|
2935
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
2936
|
+
if (fault.retryAfter !== void 0) headers.set("Retry-After", String(fault.retryAfter));
|
|
2937
|
+
return {
|
|
2938
|
+
status,
|
|
2939
|
+
headers,
|
|
2940
|
+
body: new ReadableStream({ start(controller) {
|
|
2941
|
+
controller.enqueue(utf8Encoder.encode(body));
|
|
2942
|
+
controller.close();
|
|
2943
|
+
} }),
|
|
2944
|
+
json: () => Promise.resolve(JSON.parse(body)),
|
|
2945
|
+
text: () => Promise.resolve(body),
|
|
2946
|
+
arrayBuffer: () => Promise.resolve(utf8Encoder.encode(body).buffer)
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
function rawUrlPathContainsLiteralBackslash(rawUrl) {
|
|
2950
|
+
const schemeEnd = rawUrl.indexOf("://");
|
|
2951
|
+
const authorityStart = schemeEnd === -1 ? 0 : schemeEnd + 3;
|
|
2952
|
+
const urlEndCandidates = [rawUrl.indexOf("?", authorityStart), rawUrl.indexOf("#", authorityStart)].filter((index) => index !== -1);
|
|
2953
|
+
const urlEnd = urlEndCandidates.length === 0 ? rawUrl.length : Math.min(...urlEndCandidates);
|
|
2954
|
+
const pathStartCandidates = [rawUrl.indexOf("/", authorityStart), rawUrl.indexOf("\\", authorityStart)].filter((index) => index !== -1 && index < urlEnd);
|
|
2955
|
+
if (pathStartCandidates.length === 0) return false;
|
|
2956
|
+
const pathStart = Math.min(...pathStartCandidates);
|
|
2957
|
+
const pathEndCandidates = [rawUrl.indexOf("?", pathStart), rawUrl.indexOf("#", pathStart)].filter((index) => index !== -1);
|
|
2958
|
+
const pathEnd = pathEndCandidates.length === 0 ? rawUrl.length : Math.min(...pathEndCandidates);
|
|
2959
|
+
return rawUrl.slice(pathStart, pathEnd).includes("\\");
|
|
2960
|
+
}
|
|
2961
|
+
function badRequestJson(message) {
|
|
2962
|
+
return {
|
|
2963
|
+
status: 400,
|
|
2964
|
+
body: {
|
|
2965
|
+
status: 400,
|
|
2966
|
+
code: "bad_request",
|
|
2967
|
+
message
|
|
2968
|
+
}
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
function checkedUploadBody(data, expectedLength) {
|
|
2972
|
+
if (expectedLength !== null && data.byteLength !== expectedLength) return { error: badRequestJson(contentLengthMismatchMessage(expectedLength, data.byteLength)) };
|
|
2973
|
+
return { data };
|
|
2974
|
+
}
|
|
2975
|
+
function copyBytes(bytes) {
|
|
2976
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
2977
|
+
copy.set(bytes);
|
|
2978
|
+
return copy;
|
|
2979
|
+
}
|
|
2980
|
+
function concatenateChunks(chunks, totalLength) {
|
|
2981
|
+
if (chunks.length === 0) return /* @__PURE__ */ new Uint8Array(0);
|
|
2982
|
+
if (chunks.length === 1) return copyBytes(chunks[0]);
|
|
2983
|
+
const data = new Uint8Array(totalLength);
|
|
2984
|
+
let offset = 0;
|
|
2985
|
+
for (const chunk of chunks) {
|
|
2986
|
+
data.set(chunk, offset);
|
|
2987
|
+
offset += chunk.byteLength;
|
|
2988
|
+
}
|
|
2989
|
+
return data;
|
|
2990
|
+
}
|
|
2991
|
+
async function readStreamingUploadBody(stream, expectedLength) {
|
|
2992
|
+
const reader = stream.getReader();
|
|
2993
|
+
const chunks = [];
|
|
2994
|
+
let totalLength = 0;
|
|
2995
|
+
try {
|
|
2996
|
+
while (true) {
|
|
2997
|
+
const { done, value } = await reader.read();
|
|
2998
|
+
if (done) break;
|
|
2999
|
+
if (value === void 0) continue;
|
|
3000
|
+
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
3001
|
+
totalLength += chunk.byteLength;
|
|
3002
|
+
if (expectedLength !== null && totalLength > expectedLength) {
|
|
3003
|
+
await reader.cancel().catch(() => void 0);
|
|
3004
|
+
return { error: badRequestJson(contentLengthMismatchMessage(expectedLength, totalLength)) };
|
|
3005
|
+
}
|
|
3006
|
+
chunks.push(chunk);
|
|
3007
|
+
}
|
|
3008
|
+
} finally {
|
|
3009
|
+
reader.releaseLock();
|
|
3010
|
+
}
|
|
3011
|
+
if (expectedLength !== null && totalLength !== expectedLength) return { error: badRequestJson(contentLengthMismatchMessage(expectedLength, totalLength)) };
|
|
3012
|
+
return { data: concatenateChunks(chunks, totalLength) };
|
|
1466
3013
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
if (request.headers) {
|
|
1479
|
-
for (const [k, v] of Object.entries(request.headers)) {
|
|
1480
|
-
headers[k.toLowerCase()] = v;
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
const isUpload = url.includes("b2_upload_file") || url.includes("b2_upload_part");
|
|
1484
|
-
const parsedUrl = new URL(url);
|
|
1485
|
-
const isDownload = parsedUrl.pathname.includes("b2_download_file_by_id") || parsedUrl.pathname.includes("/file/");
|
|
1486
|
-
if (isDownload) {
|
|
1487
|
-
const method = request.method === "HEAD" ? "HEAD" : "GET";
|
|
1488
|
-
const result2 = this.sim.handleDownload(parsedUrl.pathname + parsedUrl.search, headers, method);
|
|
1489
|
-
const data = result2.data ?? new Uint8Array(0);
|
|
1490
|
-
const responseHeaders = new Headers(result2.headers);
|
|
1491
|
-
responseHeaders.set(
|
|
1492
|
-
"Content-Type",
|
|
1493
|
-
result2.headers["Content-Type"] ?? "application/octet-stream"
|
|
1494
|
-
);
|
|
1495
|
-
const body = method === "HEAD" || result2.data === null ? null : new ReadableStream({
|
|
1496
|
-
start(controller) {
|
|
1497
|
-
controller.enqueue(data);
|
|
1498
|
-
controller.close();
|
|
1499
|
-
}
|
|
1500
|
-
});
|
|
1501
|
-
return {
|
|
1502
|
-
status: result2.status,
|
|
1503
|
-
headers: responseHeaders,
|
|
1504
|
-
body,
|
|
1505
|
-
json: () => Promise.reject(new Error("Download response is not JSON")),
|
|
1506
|
-
text: () => Promise.resolve(utf8Decoder.decode(data)),
|
|
1507
|
-
arrayBuffer: () => Promise.resolve(
|
|
1508
|
-
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
|
1509
|
-
)
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1512
|
-
let result;
|
|
1513
|
-
if (isUpload && request.body) {
|
|
1514
|
-
const data = new Uint8Array(
|
|
1515
|
-
request.body instanceof ArrayBuffer ? request.body : request.body instanceof Uint8Array ? request.body.buffer.slice(
|
|
1516
|
-
request.body.byteOffset,
|
|
1517
|
-
request.body.byteOffset + request.body.byteLength
|
|
1518
|
-
) : await new Response(request.body).arrayBuffer()
|
|
1519
|
-
);
|
|
1520
|
-
result = this.sim.handleUpload(url, headers, data);
|
|
1521
|
-
} else {
|
|
1522
|
-
let body = null;
|
|
1523
|
-
if (request.body) {
|
|
1524
|
-
const text = typeof request.body === "string" ? request.body : await new Response(request.body).text();
|
|
1525
|
-
try {
|
|
1526
|
-
body = JSON.parse(text);
|
|
1527
|
-
} catch {
|
|
1528
|
-
body = text;
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
result = await this.sim.handleRequest(request.method, parsedUrl.pathname, headers, body);
|
|
1532
|
-
}
|
|
1533
|
-
const responseBody = JSON.stringify(result.body);
|
|
1534
|
-
return {
|
|
1535
|
-
status: result.status,
|
|
1536
|
-
headers: new Headers({ "Content-Type": "application/json" }),
|
|
1537
|
-
body: new ReadableStream({
|
|
1538
|
-
start(controller) {
|
|
1539
|
-
controller.enqueue(utf8Encoder.encode(responseBody));
|
|
1540
|
-
controller.close();
|
|
1541
|
-
}
|
|
1542
|
-
}),
|
|
1543
|
-
json: () => Promise.resolve(result.body),
|
|
1544
|
-
text: () => Promise.resolve(responseBody),
|
|
1545
|
-
arrayBuffer: () => Promise.resolve(utf8Encoder.encode(responseBody).buffer)
|
|
1546
|
-
};
|
|
1547
|
-
}
|
|
3014
|
+
async function readUploadBody(body, expectedLength) {
|
|
3015
|
+
if (body instanceof ReadableStream) return readStreamingUploadBody(body, expectedLength);
|
|
3016
|
+
if (body instanceof ArrayBuffer) return checkedUploadBody(new Uint8Array(body), expectedLength);
|
|
3017
|
+
if (ArrayBuffer.isView(body)) {
|
|
3018
|
+
const view = body;
|
|
3019
|
+
return checkedUploadBody(copyBytes(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)), expectedLength);
|
|
3020
|
+
}
|
|
3021
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
3022
|
+
if (expectedLength !== null && body.size !== expectedLength) return { error: badRequestJson(contentLengthMismatchMessage(expectedLength, body.size)) };
|
|
3023
|
+
}
|
|
3024
|
+
return checkedUploadBody(new Uint8Array(await new Response(body).arrayBuffer()), expectedLength);
|
|
1548
3025
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
3026
|
+
var SimulatorTransport = class {
|
|
3027
|
+
sim;
|
|
3028
|
+
constructor(sim) {
|
|
3029
|
+
this.sim = sim;
|
|
3030
|
+
}
|
|
3031
|
+
async send(request) {
|
|
3032
|
+
const url = request.url;
|
|
3033
|
+
const fault = this.sim.consumeMatchingFault(url);
|
|
3034
|
+
if (fault !== null) return buildFaultResponse(fault);
|
|
3035
|
+
if (rawUrlPathContainsLiteralBackslash(url)) return buildFaultResponse({
|
|
3036
|
+
on: "\\",
|
|
3037
|
+
status: 400,
|
|
3038
|
+
code: "bad_request",
|
|
3039
|
+
message: "URL path must not contain literal backslashes"
|
|
3040
|
+
});
|
|
3041
|
+
const headers = {};
|
|
3042
|
+
if (request.headers) for (const [k, v] of Object.entries(request.headers)) headers[k.toLowerCase()] = v;
|
|
3043
|
+
const parsedUrl = new URL(url);
|
|
3044
|
+
const endpoint = parsedUrl.pathname.split("/").pop() ?? "";
|
|
3045
|
+
const isUpload = endpoint === "b2_upload_file" || endpoint === "b2_upload_part";
|
|
3046
|
+
if (parsedUrl.pathname.includes("b2_download_file_by_id") || parsedUrl.pathname.includes("/file/")) {
|
|
3047
|
+
const method = request.method === "HEAD" ? "HEAD" : "GET";
|
|
3048
|
+
const result = await this.sim.handleDownload(parsedUrl.pathname + parsedUrl.search, headers, method);
|
|
3049
|
+
const data = result.data ?? /* @__PURE__ */ new Uint8Array(0);
|
|
3050
|
+
const responseHeaders = new Headers(result.headers);
|
|
3051
|
+
responseHeaders.set("Content-Type", result.headers["Content-Type"] ?? "application/octet-stream");
|
|
3052
|
+
let decodedText = null;
|
|
3053
|
+
const text = () => {
|
|
3054
|
+
decodedText ??= utf8Decoder.decode(data);
|
|
3055
|
+
return decodedText;
|
|
3056
|
+
};
|
|
3057
|
+
const body = method === "HEAD" || result.data === null ? null : new ReadableStream({ start(controller) {
|
|
3058
|
+
controller.enqueue(data);
|
|
3059
|
+
controller.close();
|
|
3060
|
+
} });
|
|
3061
|
+
return {
|
|
3062
|
+
status: result.status,
|
|
3063
|
+
headers: responseHeaders,
|
|
3064
|
+
body,
|
|
3065
|
+
json: () => {
|
|
3066
|
+
try {
|
|
3067
|
+
return Promise.resolve(JSON.parse(text()));
|
|
3068
|
+
} catch {
|
|
3069
|
+
return Promise.reject(/* @__PURE__ */ new Error("Download response is not JSON"));
|
|
3070
|
+
}
|
|
3071
|
+
},
|
|
3072
|
+
text: () => Promise.resolve(text()),
|
|
3073
|
+
arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
let result;
|
|
3077
|
+
if (isUpload) {
|
|
3078
|
+
const parsedContentLength = parseContentLengthHeader(headers);
|
|
3079
|
+
if (parsedContentLength.kind === "error") result = badRequestJson(parsedContentLength.message);
|
|
3080
|
+
else {
|
|
3081
|
+
const uploadBody = await readUploadBody(request.body ?? /* @__PURE__ */ new Uint8Array(0), parsedContentLength.kind === "ok" ? parsedContentLength.expectedLength : null);
|
|
3082
|
+
result = "error" in uploadBody ? uploadBody.error : await this.sim.handleUpload(url, headers, uploadBody.data);
|
|
3083
|
+
}
|
|
3084
|
+
} else {
|
|
3085
|
+
let body = null;
|
|
3086
|
+
if (request.body) {
|
|
3087
|
+
const text = typeof request.body === "string" ? request.body : await new Response(request.body).text();
|
|
3088
|
+
try {
|
|
3089
|
+
body = JSON.parse(text);
|
|
3090
|
+
} catch {
|
|
3091
|
+
body = text;
|
|
3092
|
+
}
|
|
3093
|
+
} else if (request.method.toUpperCase() === "GET" && isPartnerQueryEndpoint(apiPathParts(parsedUrl.pathname).endpoint)) body = queryParamsBody(parsedUrl.searchParams);
|
|
3094
|
+
result = await this.sim.handleRequest(request.method, parsedUrl.origin, parsedUrl.pathname, headers, body);
|
|
3095
|
+
}
|
|
3096
|
+
const responseBody = JSON.stringify(result.body);
|
|
3097
|
+
return {
|
|
3098
|
+
status: result.status,
|
|
3099
|
+
headers: new Headers({ "Content-Type": "application/json" }),
|
|
3100
|
+
body: new ReadableStream({ start(controller) {
|
|
3101
|
+
controller.enqueue(utf8Encoder.encode(responseBody));
|
|
3102
|
+
controller.close();
|
|
3103
|
+
} }),
|
|
3104
|
+
json: () => Promise.resolve(result.body),
|
|
3105
|
+
text: () => Promise.resolve(responseBody),
|
|
3106
|
+
arrayBuffer: () => Promise.resolve(utf8Encoder.encode(responseBody).buffer)
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
1559
3109
|
};
|
|
1560
|
-
//#
|
|
3110
|
+
//#endregion
|
|
3111
|
+
export { B2Simulator, BUCKET_INFO_MAX_KEYS, BUCKET_INFO_VALUE_MAX, BUCKET_NAME_MAX, BUCKET_NAME_MIN, DOWNLOAD_AUTH_DURATION_MAX_SECONDS, DOWNLOAD_AUTH_DURATION_MIN_SECONDS, FILE_INFO_TOTAL_MAX, FILE_INFO_VALUE_MAX, FILE_NAME_MAX_BYTES, KEY_NAME_MAX, KEY_NAME_MIN, LIST_ENDPOINT_CAPS };
|
|
3112
|
+
|
|
3113
|
+
//# sourceMappingURL=index.js.map
|