@aws-blocks/bb-file-bucket 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +197 -0
  3. package/dist/bucket-name.d.ts +9 -0
  4. package/dist/bucket-name.d.ts.map +1 -0
  5. package/dist/bucket-name.js +62 -0
  6. package/dist/bucket-name.test.d.ts +2 -0
  7. package/dist/bucket-name.test.d.ts.map +1 -0
  8. package/dist/bucket-name.test.js +61 -0
  9. package/dist/errors.d.ts +20 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +21 -0
  12. package/dist/file-server.d.ts +14 -0
  13. package/dist/file-server.d.ts.map +1 -0
  14. package/dist/file-server.js +169 -0
  15. package/dist/file-server.test.d.ts +2 -0
  16. package/dist/file-server.test.d.ts.map +1 -0
  17. package/dist/file-server.test.js +307 -0
  18. package/dist/index.aws.d.ts +47 -0
  19. package/dist/index.aws.d.ts.map +1 -0
  20. package/dist/index.aws.js +183 -0
  21. package/dist/index.browser.d.ts +4 -0
  22. package/dist/index.browser.d.ts.map +1 -0
  23. package/dist/index.browser.js +6 -0
  24. package/dist/index.cdk.d.ts +16 -0
  25. package/dist/index.cdk.d.ts.map +1 -0
  26. package/dist/index.cdk.js +75 -0
  27. package/dist/index.cdk.test.d.ts +2 -0
  28. package/dist/index.cdk.test.d.ts.map +1 -0
  29. package/dist/index.cdk.test.js +69 -0
  30. package/dist/index.mock.d.ts +246 -0
  31. package/dist/index.mock.d.ts.map +1 -0
  32. package/dist/index.mock.js +502 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +318 -0
  36. package/dist/middleware.d.ts +3 -0
  37. package/dist/middleware.d.ts.map +1 -0
  38. package/dist/middleware.js +62 -0
  39. package/dist/mock-middleware.d.ts +3 -0
  40. package/dist/mock-middleware.d.ts.map +1 -0
  41. package/dist/mock-middleware.js +62 -0
  42. package/dist/mock-utils.d.ts +11 -0
  43. package/dist/mock-utils.d.ts.map +1 -0
  44. package/dist/mock-utils.js +28 -0
  45. package/dist/path-containment.test.d.ts +2 -0
  46. package/dist/path-containment.test.d.ts.map +1 -0
  47. package/dist/path-containment.test.js +91 -0
  48. package/dist/paths.d.ts +25 -0
  49. package/dist/paths.d.ts.map +1 -0
  50. package/dist/paths.js +67 -0
  51. package/dist/scan.test.d.ts +2 -0
  52. package/dist/scan.test.d.ts.map +1 -0
  53. package/dist/scan.test.js +107 -0
  54. package/dist/tokens.d.ts +12 -0
  55. package/dist/tokens.d.ts.map +1 -0
  56. package/dist/tokens.js +42 -0
  57. package/dist/types.d.ts +170 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +3 -0
  60. package/dist/url-encoding.test.d.ts +2 -0
  61. package/dist/url-encoding.test.d.ts.map +1 -0
  62. package/dist/url-encoding.test.js +88 -0
  63. package/dist/version.d.ts +3 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +3 -0
  66. package/package.json +57 -0
  67. package/src/bucket-name.test.ts +103 -0
  68. package/src/bucket-name.ts +83 -0
  69. package/src/errors.ts +22 -0
  70. package/src/file-server.test.ts +381 -0
  71. package/src/file-server.ts +203 -0
  72. package/src/index.aws.ts +219 -0
  73. package/src/index.browser.ts +7 -0
  74. package/src/index.cdk.test.ts +84 -0
  75. package/src/index.cdk.ts +89 -0
  76. package/src/index.mock.ts +531 -0
  77. package/src/index.test.ts +366 -0
  78. package/src/middleware.ts +66 -0
  79. package/src/mock-middleware.ts +66 -0
  80. package/src/mock-utils.ts +31 -0
  81. package/src/path-containment.test.ts +122 -0
  82. package/src/paths.ts +78 -0
  83. package/src/scan.test.ts +137 -0
  84. package/src/tokens.ts +61 -0
  85. package/src/types.ts +206 -0
  86. package/src/url-encoding.test.ts +120 -0
  87. package/src/version.ts +3 -0
@@ -0,0 +1,103 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Tests for the shared S3 bucket-name validator and its enforcement in the
6
+ * mock entry point. The CDK side is covered in index.cdk.test.ts; this file
7
+ * pins the validator rules and the local-dev (mock) parity behavior.
8
+ */
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { Scope, isBlocksError } from '@aws-blocks/core';
12
+ import { validateBucketName } from './bucket-name.js';
13
+ import { FileBucket } from './index.mock.js';
14
+
15
+ // ── Validator unit tests ─────────────────────────────────────────────────────
16
+
17
+ test('accepts a valid lowercase hyphenated name', () => {
18
+ assert.doesNotThrow(() => validateBucketName('myapp-uploads'));
19
+ });
20
+
21
+ test('accepts a name at exactly 63 characters', () => {
22
+ const name = 'a'.repeat(63);
23
+ assert.doesNotThrow(() => validateBucketName(name));
24
+ });
25
+
26
+ test('rejects a name over 63 characters with an actionable message', () => {
27
+ const name = 'a'.repeat(64);
28
+ assert.throws(
29
+ () => validateBucketName(name),
30
+ (err: unknown) =>
31
+ isBlocksError(err, 'ValidationFailed') &&
32
+ /64 characters/.test((err as Error).message) &&
33
+ /63-character limit/.test((err as Error).message) &&
34
+ /Shorten/.test((err as Error).message),
35
+ );
36
+ });
37
+
38
+ test('rejects a name under 3 characters', () => {
39
+ assert.throws(
40
+ () => validateBucketName('ab'),
41
+ (err: unknown) => isBlocksError(err, 'ValidationFailed') && /at least 3/.test((err as Error).message),
42
+ );
43
+ });
44
+
45
+ test('rejects uppercase characters', () => {
46
+ assert.throws(
47
+ () => validateBucketName('MyApp-Uploads'),
48
+ (err: unknown) => isBlocksError(err, 'ValidationFailed') && /lowercase/.test((err as Error).message),
49
+ );
50
+ });
51
+
52
+ test('rejects underscores', () => {
53
+ assert.throws(
54
+ () => validateBucketName('my_app_uploads'),
55
+ (err: unknown) => isBlocksError(err, 'ValidationFailed'),
56
+ );
57
+ });
58
+
59
+ test('rejects a name not starting with a letter or number', () => {
60
+ assert.throws(
61
+ () => validateBucketName('-myapp'),
62
+ (err: unknown) => isBlocksError(err, 'ValidationFailed') && /begin and end/.test((err as Error).message),
63
+ );
64
+ });
65
+
66
+ test('rejects a name not ending with a letter or number', () => {
67
+ assert.throws(
68
+ () => validateBucketName('myapp-'),
69
+ (err: unknown) => isBlocksError(err, 'ValidationFailed') && /begin and end/.test((err as Error).message),
70
+ );
71
+ });
72
+
73
+ test('rejects adjacent dots', () => {
74
+ assert.throws(
75
+ () => validateBucketName('my..app'),
76
+ (err: unknown) => isBlocksError(err, 'ValidationFailed') && /adjacent dots/.test((err as Error).message),
77
+ );
78
+ });
79
+
80
+ // ── Mock parity: construction enforces the same rules ────────────────────────
81
+
82
+ test('mock: constructing a FileBucket with a too-long derived name throws', () => {
83
+ // Parent id + child id joined with "-" must exceed 63 chars.
84
+ const longParent = new Scope('p'.repeat(60));
85
+ assert.throws(
86
+ () => new FileBucket(longParent, 'uploads'),
87
+ (err: unknown) => isBlocksError(err, 'ValidationFailed') && /63-character limit/.test((err as Error).message),
88
+ );
89
+ });
90
+
91
+ test('mock: constructing a FileBucket with a valid derived name succeeds', () => {
92
+ const parent = new Scope('shortapp');
93
+ assert.doesNotThrow(() => new FileBucket(parent, 'uploads'));
94
+ });
95
+
96
+ test('mock: fromExisting bypasses derived-name validation', () => {
97
+ // An over-long scope chain would normally fail, but fromExisting wraps an
98
+ // externally-named bucket so the derived name is not used.
99
+ const longParent = new Scope('p'.repeat(60));
100
+ assert.doesNotThrow(() =>
101
+ new FileBucket(longParent, 'uploads', { bucket: FileBucket.fromExisting('preexisting-bucket-123') }),
102
+ );
103
+ });
@@ -0,0 +1,83 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * S3 bucket-name validation, shared by the CDK (synth) and mock (local dev)
6
+ * entry points so a name that would be rejected by CloudFormation fails the
7
+ * same way during `bb dev` — long before a deploy is attempted.
8
+ *
9
+ * Bucket names are derived from the scope chain (`scope.fullId`). Because S3
10
+ * bucket names are globally unique and immutable, we deliberately error rather
11
+ * than silently truncate/hash: a truncated name could collide, and a name that
12
+ * shifts between deploys would orphan or replace the customer's data. The fix
13
+ * belongs in the developer's hands — shorten a scope id once and the name is
14
+ * stable forever.
15
+ *
16
+ * Rules enforced (AWS general-purpose bucket naming):
17
+ * - 3–63 characters
18
+ * - lowercase letters, numbers, dots (`.`), and hyphens (`-`) only
19
+ * - must begin and end with a letter or number
20
+ * - must not contain two adjacent dots
21
+ *
22
+ * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
23
+ */
24
+
25
+ const MIN_LEN = 3;
26
+ const MAX_LEN = 63;
27
+
28
+ function blocksError(name: string, message: string): Error {
29
+ const err = new Error(`${name}: ${message}`);
30
+ err.name = name;
31
+ return err;
32
+ }
33
+
34
+ /**
35
+ * Validate an auto-derived S3 bucket name. Throws a `ValidationFailed` error
36
+ * with an actionable message when the name violates an S3 naming rule.
37
+ *
38
+ * @param name - The bucket name (the scope's `fullId`).
39
+ * @throws {Error} With name `ValidationFailed` if the name is invalid.
40
+ */
41
+ export function validateBucketName(name: string): void {
42
+ const hint =
43
+ `FileBucket names are derived from the scope chain (id of the bucket ` +
44
+ `plus its parent scopes, joined with "-"). Shorten the FileBucket id ` +
45
+ `or a parent scope id, or pass an existing bucket via ` +
46
+ `FileBucket.fromExisting(...).`;
47
+
48
+ if (name.length > MAX_LEN) {
49
+ throw blocksError(
50
+ 'ValidationFailed',
51
+ `Derived bucket name "${name}" is ${name.length} characters, ` +
52
+ `exceeding S3's ${MAX_LEN}-character limit. ${hint}`,
53
+ );
54
+ }
55
+ if (name.length < MIN_LEN) {
56
+ throw blocksError(
57
+ 'ValidationFailed',
58
+ `Derived bucket name "${name}" is ${name.length} characters; ` +
59
+ `S3 requires at least ${MIN_LEN}. ${hint}`,
60
+ );
61
+ }
62
+ if (!/^[a-z0-9.-]+$/.test(name)) {
63
+ throw blocksError(
64
+ 'ValidationFailed',
65
+ `Derived bucket name "${name}" contains characters that are invalid ` +
66
+ `for an S3 bucket. Use only lowercase letters, numbers, dots (.), ` +
67
+ `and hyphens (-). ${hint}`,
68
+ );
69
+ }
70
+ if (!/^[a-z0-9]/.test(name) || !/[a-z0-9]$/.test(name)) {
71
+ throw blocksError(
72
+ 'ValidationFailed',
73
+ `Derived bucket name "${name}" must begin and end with a lowercase ` +
74
+ `letter or number. ${hint}`,
75
+ );
76
+ }
77
+ if (name.includes('..')) {
78
+ throw blocksError(
79
+ 'ValidationFailed',
80
+ `Derived bucket name "${name}" must not contain two adjacent dots. ${hint}`,
81
+ );
82
+ }
83
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Typed error constants for FileBucket. Use with `isBlocksError()` in catch blocks.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * try {
10
+ * const file = await bucket.get('missing.txt');
11
+ * } catch (e: unknown) {
12
+ * if (isBlocksError(e, FileBucketErrors.FileNotFound)) {
13
+ * // file does not exist
14
+ * }
15
+ * throw e;
16
+ * }
17
+ * ```
18
+ */
19
+ export const FileBucketErrors = {
20
+ FileNotFound: 'NoSuchKey',
21
+ FileTooLarge: 'EntityTooLarge',
22
+ } as const;
@@ -0,0 +1,381 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Tests for the dev file-server attachment — presigned URL serving,
6
+ * path encoding/decoding, and versioning integration.
7
+ */
8
+
9
+ import { test, describe, beforeEach, afterEach } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { rmSync, existsSync, readdirSync } from 'node:fs';
12
+ import { createServer } from 'node:http';
13
+ import type { Server } from 'node:http';
14
+ import { Scope } from '@aws-blocks/core';
15
+ import { FileBucket } from './index.mock.js';
16
+ import { attach } from './file-server.js';
17
+ import { mintFileToken, LOCAL_FILE_SECRET } from './tokens.js';
18
+
19
+ const scope = new Scope('fsrv');
20
+
21
+ let server: Server;
22
+ let port: number;
23
+
24
+ beforeEach(async () => {
25
+ const dir = '.bb-data';
26
+ try {
27
+ if (existsSync(dir)) {
28
+ for (const entry of readdirSync(dir)) {
29
+ if (entry.startsWith('fsrv-')) {
30
+ rmSync(`${dir}/${entry}`, { recursive: true, force: true });
31
+ }
32
+ }
33
+ }
34
+ } catch {}
35
+ server = createServer((_req, res) => {
36
+ res.writeHead(404);
37
+ res.end('not found');
38
+ });
39
+ attach(server);
40
+ port = await new Promise<number>((resolve) => {
41
+ server.listen(0, () => {
42
+ const addr = server.address();
43
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
44
+ });
45
+ });
46
+ });
47
+
48
+ afterEach(() => {
49
+ server.close();
50
+ });
51
+
52
+ // ── Basic presigned URL round-trip ──────────────────────────────────────────
53
+
54
+ describe('file-server: basic GET/PUT', () => {
55
+ test('PUT then GET via presigned URLs', async () => {
56
+ const bucket = new FileBucket(scope, 'fs-basic');
57
+ const putUrl = await bucket.putUrl('hello.txt', { contentType: 'text/plain' });
58
+ const adjustedPut = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
59
+
60
+ const putRes = await fetch(adjustedPut, {
61
+ method: 'PUT',
62
+ body: 'hello world',
63
+ headers: { 'Content-Type': 'text/plain' },
64
+ });
65
+ assert.strictEqual(putRes.status, 200, `PUT failed: ${putRes.status}`);
66
+
67
+ const getUrl = await bucket.getUrl('hello.txt');
68
+ const adjustedGet = getUrl.replace(/localhost:\d+/, `localhost:${port}`);
69
+
70
+ const getRes = await fetch(adjustedGet);
71
+ assert.strictEqual(getRes.status, 200, `GET failed: ${getRes.status}`);
72
+ assert.strictEqual(await getRes.text(), 'hello world');
73
+ });
74
+
75
+ test('GET non-existent file returns 404', async () => {
76
+ const bucket = new FileBucket(scope, 'fs-404');
77
+ const url = await bucket.getUrl('missing.txt');
78
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
79
+
80
+ const res = await fetch(adjusted);
81
+ assert.strictEqual(res.status, 404);
82
+ });
83
+
84
+ test('invalid token returns 403', async () => {
85
+ const url = `http://localhost:${port}/.bb-file-bucket/root-test/file.txt?token=invalid.token`;
86
+ const res = await fetch(url);
87
+ assert.strictEqual(res.status, 403);
88
+ });
89
+
90
+ test('PUT for an unregistered bucket fails loud (500), no silent write', async () => {
91
+ // Mint a structurally valid token for a fullId that has no FileBucket
92
+ // instance registered. The server must refuse rather than fall back to
93
+ // an unversioned direct write.
94
+ const unknownId = 'fsrv-unregistered';
95
+ const token = mintFileToken(unknownId, 'orphan.txt', 'PUT', 3600, LOCAL_FILE_SECRET, 'text/plain');
96
+ const url = `http://localhost:${port}/.bb-file-bucket/${unknownId}/orphan.txt?token=${token}`;
97
+
98
+ const res = await fetch(url, {
99
+ method: 'PUT',
100
+ body: 'should not be written',
101
+ headers: { 'Content-Type': 'text/plain' },
102
+ });
103
+ assert.strictEqual(res.status, 500, `Expected 500 for unregistered bucket, got ${res.status}`);
104
+ });
105
+ });
106
+
107
+ // ── Content-Type parity with S3 presigned PUT ───────────────────────────────
108
+
109
+ describe('file-server: Content-Type signing parity', () => {
110
+ test('PUT with a Content-Type that differs from the signed value returns 403', async () => {
111
+ const bucket = new FileBucket(scope, 'fs-ct1');
112
+ // URL is signed for image/png …
113
+ const putUrl = await bucket.putUrl('avatar', { contentType: 'image/png' });
114
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
115
+
116
+ // … but the client uploads with image/jpeg. Real S3 rejects this with
117
+ // SignatureDoesNotMatch; the mock must do the same so the failure shows
118
+ // up locally instead of only in prod.
119
+ const res = await fetch(adjusted, {
120
+ method: 'PUT',
121
+ body: 'fake png bytes',
122
+ headers: { 'Content-Type': 'image/jpeg' },
123
+ });
124
+ assert.strictEqual(res.status, 403, `Expected 403 for mismatched Content-Type, got ${res.status}`);
125
+
126
+ // And nothing was written.
127
+ assert.strictEqual(await bucket.get('avatar'), null, 'mismatched upload must not be stored');
128
+ });
129
+
130
+ test('PUT omitting Content-Type when one was signed returns 403', async () => {
131
+ const bucket = new FileBucket(scope, 'fs-ct2');
132
+ const putUrl = await bucket.putUrl('doc.pdf', { contentType: 'application/pdf' });
133
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
134
+
135
+ // node-fetch/undici defaults a string body to text/plain; send an empty
136
+ // body with no explicit Content-Type isn't reliable across runtimes, so
137
+ // assert the realistic case: a wrong/absent signed header is rejected.
138
+ const res = await fetch(adjusted, {
139
+ method: 'PUT',
140
+ body: new Blob(['data']), // Blob with no type → Content-Type omitted by undici
141
+ });
142
+ assert.strictEqual(res.status, 403, `Expected 403 when signed Content-Type is missing, got ${res.status}`);
143
+ });
144
+
145
+ test('PUT with a matching Content-Type succeeds and stores it', async () => {
146
+ const bucket = new FileBucket(scope, 'fs-ct3');
147
+ const putUrl = await bucket.putUrl('report.csv', { contentType: 'text/csv' });
148
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
149
+
150
+ const res = await fetch(adjusted, {
151
+ method: 'PUT',
152
+ body: 'a,b,c',
153
+ headers: { 'Content-Type': 'text/csv' },
154
+ });
155
+ assert.strictEqual(res.status, 200, `Expected 200 for matching Content-Type, got ${res.status}`);
156
+
157
+ const file = await bucket.get('report.csv');
158
+ assert.ok(file, 'matching upload should be stored');
159
+ assert.strictEqual(file.contentType, 'text/csv');
160
+ assert.strictEqual(file.body.toString(), 'a,b,c');
161
+ });
162
+
163
+ test('createUploadHandle round-trips because it sends the signed Content-Type', async () => {
164
+ // The typed handle path sets the request header from the same value it
165
+ // signs, so it stays consistent across mock and prod. This pins that the
166
+ // new enforcement does not regress the happy path.
167
+ const bucket = new FileBucket(scope, 'fs-ct4');
168
+ const handle = await bucket.createUploadHandle('photo.jpg', { contentType: 'image/jpeg' });
169
+ const adjusted = handle.getUrl().replace(/localhost:\d+/, `localhost:${port}`);
170
+
171
+ const res = await fetch(adjusted, {
172
+ method: 'PUT',
173
+ body: 'jpeg bytes',
174
+ headers: { 'Content-Type': 'image/jpeg' },
175
+ });
176
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}`);
177
+
178
+ const file = await bucket.get('photo.jpg');
179
+ assert.ok(file);
180
+ assert.strictEqual(file.contentType, 'image/jpeg');
181
+ });
182
+
183
+ test('PUT without a signed Content-Type accepts any request header', async () => {
184
+ // putUrl without contentType signs no content-type, mirroring an S3
185
+ // presigned URL that did not include it — any upload header is allowed.
186
+ const bucket = new FileBucket(scope, 'fs-ct5');
187
+ const putUrl = await bucket.putUrl('blob.bin');
188
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
189
+
190
+ const res = await fetch(adjusted, {
191
+ method: 'PUT',
192
+ body: 'anything',
193
+ headers: { 'Content-Type': 'application/x-custom' },
194
+ });
195
+ assert.strictEqual(res.status, 200, `Expected 200 when no Content-Type was signed, got ${res.status}`);
196
+
197
+ const file = await bucket.get('blob.bin');
198
+ assert.ok(file);
199
+ assert.strictEqual(file.contentType, 'application/x-custom');
200
+ });
201
+ });
202
+
203
+ // ── Path encoding/decoding ──────────────────────────────────────────────────
204
+
205
+ describe('file-server: URL-encoded paths', () => {
206
+ test('GET with spaces in path (encoded as %20)', async () => {
207
+ const bucket = new FileBucket(scope, 'fs-enc1');
208
+ await bucket.put('my folder/my file.txt', 'spaced content', { contentType: 'text/plain' });
209
+
210
+ const token = mintFileToken('fsrv-fs-enc1', 'my folder/my file.txt', 'GET', 3600, LOCAL_FILE_SECRET);
211
+ const encodedPath = 'my%20folder/my%20file.txt';
212
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc1/${encodedPath}?token=${token}`;
213
+
214
+ const res = await fetch(url);
215
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
216
+ assert.strictEqual(await res.text(), 'spaced content');
217
+ });
218
+
219
+ test('GET with # in filename (encoded as %23)', async () => {
220
+ const bucket = new FileBucket(scope, 'fs-enc2');
221
+ await bucket.put('file#1.txt', 'hash content', { contentType: 'text/plain' });
222
+
223
+ const token = mintFileToken('fsrv-fs-enc2', 'file#1.txt', 'GET', 3600, LOCAL_FILE_SECRET);
224
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc2/file%231.txt?token=${token}`;
225
+
226
+ const res = await fetch(url);
227
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
228
+ assert.strictEqual(await res.text(), 'hash content');
229
+ });
230
+
231
+ test('GET with + in filename (should not decode as space)', async () => {
232
+ const bucket = new FileBucket(scope, 'fs-enc3');
233
+ await bucket.put('a+b.txt', 'plus content', { contentType: 'text/plain' });
234
+
235
+ const token = mintFileToken('fsrv-fs-enc3', 'a+b.txt', 'GET', 3600, LOCAL_FILE_SECRET);
236
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc3/a%2Bb.txt?token=${token}`;
237
+
238
+ const res = await fetch(url);
239
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
240
+ assert.strictEqual(await res.text(), 'plus content');
241
+ });
242
+
243
+ test('GET with unicode characters (encoded)', async () => {
244
+ const bucket = new FileBucket(scope, 'fs-enc4');
245
+ await bucket.put('文件/数据.txt', 'unicode content', { contentType: 'text/plain' });
246
+
247
+ const token = mintFileToken('fsrv-fs-enc4', '文件/数据.txt', 'GET', 3600, LOCAL_FILE_SECRET);
248
+ const encodedPath = encodeURIComponent('文件') + '/' + encodeURIComponent('数据.txt');
249
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc4/${encodedPath}?token=${token}`;
250
+
251
+ const res = await fetch(url);
252
+ assert.strictEqual(res.status, 200, `Expected 200, got ${res.status}: ${await res.clone().text()}`);
253
+ assert.strictEqual(await res.text(), 'unicode content');
254
+ });
255
+
256
+ test('PUT with spaces in path (encoded)', async () => {
257
+ const bucket = new FileBucket(scope, 'fs-enc5');
258
+ const token = mintFileToken('fsrv-fs-enc5', 'dir name/file name.txt', 'PUT', 3600, LOCAL_FILE_SECRET, 'text/plain');
259
+ const encodedPath = 'dir%20name/file%20name.txt';
260
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-enc5/${encodedPath}?token=${token}`;
261
+
262
+ const putRes = await fetch(url, {
263
+ method: 'PUT',
264
+ body: 'uploaded with spaces',
265
+ headers: { 'Content-Type': 'text/plain' },
266
+ });
267
+ assert.strictEqual(putRes.status, 200, `PUT failed: ${putRes.status}`);
268
+
269
+ // Verify via bucket API
270
+ const file = await bucket.get('dir name/file name.txt');
271
+ assert.ok(file, 'File should exist after presigned PUT');
272
+ assert.strictEqual(file.body.toString(), 'uploaded with spaces');
273
+ });
274
+ });
275
+
276
+ // ── Versioning integration ──────────────────────────────────────────────────
277
+
278
+ describe('file-server: versioning', () => {
279
+ test('PUT via presigned URL creates a new version', async () => {
280
+ const bucket = new FileBucket(scope, 'fs-ver1', { versioned: true });
281
+ await bucket.put('doc.txt', 'v1-api', { contentType: 'text/plain' });
282
+
283
+ const vBefore = await bucket.listVersions('doc.txt');
284
+ assert.strictEqual(vBefore.length, 1);
285
+
286
+ // Upload v2 via presigned URL
287
+ const putUrl = await bucket.putUrl('doc.txt', { contentType: 'text/plain' });
288
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
289
+ const res = await fetch(adjusted, {
290
+ method: 'PUT',
291
+ body: 'v2-presigned',
292
+ headers: { 'Content-Type': 'text/plain' },
293
+ });
294
+ assert.strictEqual(res.status, 200);
295
+
296
+ const vAfter = await bucket.listVersions('doc.txt');
297
+ assert.strictEqual(
298
+ vAfter.length, 2,
299
+ `Expected 2 versions after presigned PUT, got ${vAfter.length}`,
300
+ );
301
+
302
+ const current = await bucket.get('doc.txt');
303
+ assert.ok(current);
304
+ assert.strictEqual(current.body.toString(), 'v2-presigned');
305
+ });
306
+
307
+ test('GET with versionId via presigned URL returns specific version', async () => {
308
+ const bucket = new FileBucket(scope, 'fs-ver2', { versioned: true });
309
+ await bucket.put('doc.txt', 'v1', { contentType: 'text/plain' });
310
+ await bucket.put('doc.txt', 'v2', { contentType: 'text/plain' });
311
+
312
+ const versions = await bucket.listVersions('doc.txt');
313
+ const oldVersion = versions[versions.length - 1];
314
+
315
+ const url = await bucket.getUrl('doc.txt', { versionId: oldVersion.versionId });
316
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
317
+
318
+ const res = await fetch(adjusted);
319
+ assert.strictEqual(res.status, 200);
320
+ assert.strictEqual(await res.text(), 'v1');
321
+ });
322
+
323
+ test('rejects a traversal versionId (path-traversal guard)', async () => {
324
+ const bucket = new FileBucket(scope, 'fs-ver-traversal', { versioned: true });
325
+ await bucket.put('doc.txt', 'secret-contents', { contentType: 'text/plain' });
326
+
327
+ // Start from a legitimate presigned GET URL, then tamper with versionId.
328
+ const url = await bucket.getUrl('doc.txt');
329
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
330
+ const malicious = `${adjusted}${adjusted.includes('?') ? '&' : '?'}versionId=${encodeURIComponent('../../../../../../etc/passwd')}`;
331
+
332
+ const res = await fetch(malicious);
333
+ assert.strictEqual(res.status, 400, 'traversal versionId must be rejected with 400');
334
+ const body = await res.text();
335
+ assert.ok(!body.includes('root:'), 'must not leak /etc/passwd contents');
336
+ });
337
+
338
+ test('rejects a prefix-bypass versionId (anchored regex guard)', async () => {
339
+ const bucket = new FileBucket(scope, 'fs-ver-prefix-bypass', { versioned: true });
340
+ await bucket.put('doc.txt', 'secret-contents', { contentType: 'text/plain' });
341
+
342
+ // Attempt to bypass with a versionId that starts valid but appends traversal.
343
+ const url = await bucket.getUrl('doc.txt');
344
+ const adjusted = url.replace(/localhost:\d+/, `localhost:${port}`);
345
+ const malicious = `${adjusted}${adjusted.includes('?') ? '&' : '?'}versionId=${encodeURIComponent('v1/../../../etc/passwd')}`;
346
+
347
+ const res = await fetch(malicious);
348
+ assert.strictEqual(res.status, 400, 'prefix-bypass versionId must be rejected with 400');
349
+ });
350
+
351
+ test('multiple presigned PUTs each create a version', async () => {
352
+ const bucket = new FileBucket(scope, 'fs-ver3', { versioned: true });
353
+
354
+ for (let i = 1; i <= 3; i++) {
355
+ const putUrl = await bucket.putUrl('counter.txt', { contentType: 'text/plain' });
356
+ const adjusted = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
357
+ await fetch(adjusted, {
358
+ method: 'PUT',
359
+ body: `version-${i}`,
360
+ headers: { 'Content-Type': 'text/plain' },
361
+ });
362
+ }
363
+
364
+ const versions = await bucket.listVersions('counter.txt');
365
+ assert.strictEqual(
366
+ versions.length, 3,
367
+ `Expected 3 versions, got ${versions.length}`,
368
+ );
369
+ });
370
+ });
371
+
372
+ // ── CORS headers ────────────────────────────────────────────────────────────
373
+
374
+ describe('file-server: CORS', () => {
375
+ test('OPTIONS request returns CORS headers', async () => {
376
+ const url = `http://localhost:${port}/.bb-file-bucket/any/path?token=x`;
377
+ const res = await fetch(url, { method: 'OPTIONS' });
378
+ assert.strictEqual(res.status, 200);
379
+ assert.ok(res.headers.get('access-control-allow-methods')?.includes('PUT'));
380
+ });
381
+ });