@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,366 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { test, beforeEach } from 'node:test';
5
+ import assert from 'node:assert';
6
+ import { rmSync, existsSync, readdirSync } from 'node:fs';
7
+ import { Scope } from '@aws-blocks/core';
8
+ import { FileBucket, FileBucketErrors } from './index.mock.js';
9
+
10
+ const scope = new Scope('idx');
11
+
12
+ // Clean only our scope's mock data between tests
13
+ beforeEach(() => {
14
+ const dir = '.bb-data';
15
+ try {
16
+ if (existsSync(dir)) {
17
+ for (const entry of readdirSync(dir)) {
18
+ if (entry.startsWith('idx-')) {
19
+ rmSync(`${dir}/${entry}`, { recursive: true, force: true });
20
+ }
21
+ }
22
+ }
23
+ } catch {}
24
+ });
25
+
26
+ // ── Basic CRUD ──────────────────────────────────────────────────────────────
27
+
28
+ test('put and get', async () => {
29
+ const bucket = new FileBucket(scope, 'test');
30
+ await bucket.put('hello.txt', 'hello world', { contentType: 'text/plain' });
31
+ const file = await bucket.get('hello.txt');
32
+ assert.ok(file);
33
+ assert.strictEqual(file.body.toString(), 'hello world');
34
+ assert.strictEqual(file.contentType, 'text/plain');
35
+ assert.strictEqual(file.size, 11);
36
+ });
37
+
38
+ test('put with Buffer body', async () => {
39
+ const bucket = new FileBucket(scope, 'test');
40
+ const buf = Buffer.from([0x00, 0x01, 0x02]);
41
+ await bucket.put('binary.bin', buf);
42
+ const file = await bucket.get('binary.bin');
43
+ assert.ok(file);
44
+ assert.deepStrictEqual(file.body, buf);
45
+ });
46
+
47
+ test('get non-existent file returns null', async () => {
48
+ const bucket = new FileBucket(scope, 'test');
49
+ assert.strictEqual(await bucket.get('nonexistent.txt'), null);
50
+ });
51
+
52
+ test('put overwrites existing file', async () => {
53
+ const bucket = new FileBucket(scope, 'test');
54
+ await bucket.put('file.txt', 'v1');
55
+ await bucket.put('file.txt', 'v2');
56
+ const file = await bucket.get('file.txt');
57
+ assert.ok(file);
58
+ assert.strictEqual(file.body.toString(), 'v2');
59
+ });
60
+
61
+ // ── Delete ──────────────────────────────────────────────────────────────────
62
+
63
+ test('delete removes file', async () => {
64
+ const bucket = new FileBucket(scope, 'test');
65
+ await bucket.put('file.txt', 'data');
66
+ await bucket.delete('file.txt');
67
+ assert.strictEqual(await bucket.get('file.txt'), null);
68
+ });
69
+
70
+ test('delete non-existent file is no-op', async () => {
71
+ const bucket = new FileBucket(scope, 'test');
72
+ await bucket.delete('nonexistent.txt'); // should not throw
73
+ });
74
+
75
+ // ── deleteBatch ─────────────────────────────────────────────────────────────
76
+
77
+ test('deleteBatch removes multiple files', async () => {
78
+ const bucket = new FileBucket(scope, 'test');
79
+ await bucket.put('a.txt', 'a');
80
+ await bucket.put('b.txt', 'b');
81
+ await bucket.put('c.txt', 'c');
82
+ await bucket.deleteBatch(['a.txt', 'b.txt']);
83
+ assert.strictEqual(await bucket.get('a.txt'), null);
84
+ assert.strictEqual(await bucket.get('b.txt'), null);
85
+ assert.ok(await bucket.get('c.txt'));
86
+ });
87
+
88
+ // ── Metadata ────────────────────────────────────────────────────────────────
89
+
90
+ test('metadata is preserved', async () => {
91
+ const bucket = new FileBucket(scope, 'test');
92
+ await bucket.put('file.txt', 'data', {
93
+ contentType: 'text/csv',
94
+ metadata: { author: 'test' },
95
+ });
96
+ const file = await bucket.get('file.txt');
97
+ assert.ok(file);
98
+ assert.strictEqual(file.contentType, 'text/csv');
99
+ assert.strictEqual(file.metadata.author, 'test');
100
+ });
101
+
102
+ test('default contentType is application/octet-stream', async () => {
103
+ const bucket = new FileBucket(scope, 'test');
104
+ await bucket.put('file.bin', Buffer.from([1]));
105
+ const file = await bucket.get('file.bin');
106
+ assert.ok(file);
107
+ assert.strictEqual(file.contentType, 'application/octet-stream');
108
+ });
109
+
110
+ // ── Nested paths ────────────────────────────────────────────────────────────
111
+
112
+ test('nested paths work', async () => {
113
+ const bucket = new FileBucket(scope, 'test');
114
+ await bucket.put('a/b/c/file.txt', 'nested');
115
+ const file = await bucket.get('a/b/c/file.txt');
116
+ assert.ok(file);
117
+ assert.strictEqual(file.body.toString(), 'nested');
118
+ });
119
+
120
+ // ── scan() ──────────────────────────────────────────────────────────────────
121
+
122
+ test('scan yields all files', async () => {
123
+ const bucket = new FileBucket(scope, 'scantest');
124
+ await bucket.put('a.txt', 'a');
125
+ await bucket.put('b.txt', 'b');
126
+ const files: { path: string }[] = [];
127
+ for await (const file of bucket.scan()) files.push(file);
128
+ assert.strictEqual(files.length, 2);
129
+ const paths = files.map(f => f.path).sort();
130
+ assert.deepStrictEqual(paths, ['a.txt', 'b.txt']);
131
+ });
132
+
133
+ test('scan with prefix filters results', async () => {
134
+ const bucket = new FileBucket(scope, 'scantest');
135
+ await bucket.put('uploads/a.txt', 'a');
136
+ await bucket.put('uploads/b.txt', 'b');
137
+ await bucket.put('reports/c.txt', 'c');
138
+ const files: { path: string }[] = [];
139
+ for await (const file of bucket.scan({ prefix: 'uploads/' })) files.push(file);
140
+ assert.strictEqual(files.length, 2);
141
+ });
142
+
143
+ test('scan on empty bucket yields nothing', async () => {
144
+ const bucket = new FileBucket(scope, 'empty');
145
+ const files: unknown[] = [];
146
+ for await (const file of bucket.scan()) files.push(file);
147
+ assert.strictEqual(files.length, 0);
148
+ });
149
+
150
+ test('scan returns size and lastModified', async () => {
151
+ const bucket = new FileBucket(scope, 'scanmeta');
152
+ await bucket.put('file.txt', 'hello');
153
+ for await (const file of bucket.scan()) {
154
+ assert.strictEqual(file.size, 5);
155
+ assert.ok(file.lastModified instanceof Date);
156
+ }
157
+ });
158
+
159
+ // ── Presigned URLs ──────────────────────────────────────────────────────────
160
+
161
+ test('getUrl returns a token-bearing URL', async () => {
162
+ const bucket = new FileBucket(scope, 'urls');
163
+ const url = await bucket.getUrl('file.txt');
164
+ assert.ok(url.includes('localhost'));
165
+ assert.ok(url.includes('file.txt'));
166
+ assert.ok(url.includes('token='));
167
+ });
168
+
169
+ test('putUrl returns a token-bearing URL', async () => {
170
+ const bucket = new FileBucket(scope, 'urls');
171
+ const url = await bucket.putUrl('file.txt');
172
+ assert.ok(url.includes('localhost'));
173
+ assert.ok(url.includes('file.txt'));
174
+ assert.ok(url.includes('token='));
175
+ });
176
+
177
+ // ── Transferable handles ────────────────────────────────────────────────────
178
+
179
+ test('getFileHandle returns a download handle with toJSON', async () => {
180
+ const bucket = new FileBucket(scope, 'handles');
181
+ const handle = await bucket.getFileHandle('file.txt');
182
+ assert.strictEqual(typeof handle.download, 'function');
183
+ assert.strictEqual(typeof handle.getUrl, 'function');
184
+ assert.ok(handle.getUrl().includes('file.txt'));
185
+ const json = handle.toJSON();
186
+ assert.strictEqual(json.__blocks, 'file-bucket/download');
187
+ assert.ok(json.url.includes('file.txt'));
188
+ });
189
+
190
+ test('createUploadHandle returns an upload handle with toJSON', async () => {
191
+ const bucket = new FileBucket(scope, 'handles');
192
+ const handle = await bucket.createUploadHandle('file.txt', { contentType: 'text/plain' });
193
+ assert.strictEqual(typeof handle.upload, 'function');
194
+ assert.strictEqual(typeof handle.getUrl, 'function');
195
+ assert.ok(handle.getUrl().includes('file.txt'));
196
+ const json = handle.toJSON();
197
+ assert.strictEqual(json.__blocks, 'file-bucket/upload');
198
+ assert.ok(json.url.includes('file.txt'));
199
+ assert.strictEqual(json.contentType, 'text/plain');
200
+ });
201
+
202
+ // ── fromExisting ────────────────────────────────────────────────────────────
203
+
204
+ test('fromExisting returns ExternalBucketRef', () => {
205
+ const ref = FileBucket.fromExisting('my-bucket');
206
+ assert.strictEqual(ref.bucketName, 'my-bucket');
207
+ });
208
+
209
+ // ── Error constants ─────────────────────────────────────────────────────────
210
+
211
+ test('FileBucketErrors has expected values', () => {
212
+ assert.strictEqual(FileBucketErrors.FileNotFound, 'NoSuchKey');
213
+ });
214
+
215
+ // ── fullId ──────────────────────────────────────────────────────────────────
216
+
217
+ test('fullId generation with parent', () => {
218
+ const bucket = new FileBucket(scope, 'child');
219
+ assert.ok(bucket.fullId.endsWith('idx-child'));
220
+ });
221
+
222
+ // ── Disk persistence ────────────────────────────────────────────────────────
223
+
224
+ test('data persists across instances', async () => {
225
+ const bucket1 = new FileBucket(scope, 'persist');
226
+ await bucket1.put('file.txt', 'saved');
227
+
228
+ const bucket2 = new FileBucket(scope, 'persist');
229
+ const file = await bucket2.get('file.txt');
230
+ assert.ok(file);
231
+ assert.strictEqual(file.body.toString(), 'saved');
232
+ });
233
+
234
+ // ── Versioned bucket ────────────────────────────────────────────────────────
235
+
236
+ test('versioned: put creates versions', async () => {
237
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
238
+ await bucket.put('file.txt', 'v1');
239
+ await bucket.put('file.txt', 'v2');
240
+ await bucket.put('file.txt', 'v3');
241
+ const versions = await bucket.listVersions('file.txt');
242
+ assert.strictEqual(versions.length, 3);
243
+ assert.strictEqual(versions[0].isCurrent, true);
244
+ assert.strictEqual(versions[1].isCurrent, false);
245
+ });
246
+
247
+ test('versioned: get returns latest by default', async () => {
248
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
249
+ await bucket.put('file.txt', 'v1');
250
+ await bucket.put('file.txt', 'v2');
251
+ const file = await bucket.get('file.txt');
252
+ assert.ok(file);
253
+ assert.strictEqual(file.body.toString(), 'v2');
254
+ });
255
+
256
+ test('versioned: get with versionId returns specific version', async () => {
257
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
258
+ await bucket.put('file.txt', 'v1');
259
+ await bucket.put('file.txt', 'v2');
260
+ const versions = await bucket.listVersions('file.txt');
261
+ const oldest = versions[versions.length - 1];
262
+ const file = await bucket.get('file.txt', { versionId: oldest.versionId });
263
+ assert.ok(file);
264
+ assert.strictEqual(file.body.toString(), 'v1');
265
+ });
266
+
267
+ test('versioned: delete without versionId places delete marker', async () => {
268
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
269
+ await bucket.put('file.txt', 'data');
270
+ await bucket.delete('file.txt');
271
+ // Current version appears deleted
272
+ assert.strictEqual(await bucket.get('file.txt'), null);
273
+ // But versions still exist
274
+ const versions = await bucket.listVersions('file.txt');
275
+ assert.strictEqual(versions.length, 1);
276
+ assert.strictEqual(versions[0].isCurrent, false); // delete marker means none is current
277
+ });
278
+
279
+ test('versioned: delete with versionId permanently removes that version', async () => {
280
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
281
+ await bucket.put('file.txt', 'v1');
282
+ await bucket.put('file.txt', 'v2');
283
+ const versions = await bucket.listVersions('file.txt');
284
+ await bucket.delete('file.txt', { versionId: versions[1].versionId });
285
+ const remaining = await bucket.listVersions('file.txt');
286
+ assert.strictEqual(remaining.length, 1);
287
+ });
288
+
289
+ test('versioned: scan skips deleted files', async () => {
290
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
291
+ await bucket.put('a.txt', 'a');
292
+ await bucket.put('b.txt', 'b');
293
+ await bucket.delete('a.txt');
294
+ const files: { path: string }[] = [];
295
+ for await (const f of bucket.scan()) files.push(f);
296
+ assert.strictEqual(files.length, 1);
297
+ assert.strictEqual(files[0].path, 'b.txt');
298
+ });
299
+
300
+ test('versioned: restoreVersion makes old version current', async () => {
301
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
302
+ await bucket.put('file.txt', 'v1');
303
+ await bucket.put('file.txt', 'v2');
304
+ const versions = await bucket.listVersions('file.txt');
305
+ const oldest = versions[versions.length - 1];
306
+ await bucket.restoreVersion('file.txt', oldest.versionId);
307
+ const file = await bucket.get('file.txt');
308
+ assert.ok(file);
309
+ assert.strictEqual(file.body.toString(), 'v1');
310
+ // Should now have 3 versions (v1, v2, restored copy)
311
+ const after = await bucket.listVersions('file.txt');
312
+ assert.strictEqual(after.length, 3);
313
+ });
314
+
315
+ test('versioned: restoreVersion after delete removes delete marker', async () => {
316
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
317
+ await bucket.put('file.txt', 'v1');
318
+ await bucket.delete('file.txt');
319
+ assert.strictEqual(await bucket.get('file.txt'), null);
320
+ const versions = await bucket.listVersions('file.txt');
321
+ await bucket.restoreVersion('file.txt', versions[0].versionId);
322
+ const file = await bucket.get('file.txt');
323
+ assert.ok(file);
324
+ assert.strictEqual(file.body.toString(), 'v1');
325
+ });
326
+
327
+ test('versioned: listVersions on non-existent file returns empty', async () => {
328
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
329
+ const versions = await bucket.listVersions('nonexistent.txt');
330
+ assert.deepStrictEqual(versions, []);
331
+ });
332
+
333
+ test('versioned: listVersions returns newest first', async () => {
334
+ const bucket = new FileBucket(scope, 'versioned', { versioned: true });
335
+ await bucket.put('file.txt', 'v1');
336
+ await bucket.put('file.txt', 'v2');
337
+ await bucket.put('file.txt', 'v3');
338
+ const versions = await bucket.listVersions('file.txt');
339
+ assert.strictEqual(versions[0].versionId, 'v3');
340
+ assert.strictEqual(versions[1].versionId, 'v2');
341
+ assert.strictEqual(versions[2].versionId, 'v1');
342
+ });
343
+
344
+ // ── Static type checks: conditional version types ───────────────────────────
345
+
346
+ function _conditionalVersionTypeChecks() {
347
+ const plain = new FileBucket(scope, 'plain');
348
+ const versioned = new FileBucket(scope, 'versioned', { versioned: true });
349
+
350
+ // Non-versioned: get/delete accept no options
351
+ plain.get('file.txt');
352
+ plain.delete('file.txt');
353
+ // @ts-expect-error — versionId not allowed on non-versioned bucket
354
+ plain.get('file.txt', { versionId: 'v1' });
355
+ // @ts-expect-error — versionId not allowed on non-versioned bucket
356
+ plain.delete('file.txt', { versionId: 'v1' });
357
+ // @ts-expect-error — versionId not allowed on non-versioned getUrl
358
+ plain.getUrl('file.txt', { versionId: 'v1' });
359
+
360
+ // Versioned: get/delete accept optional versionId
361
+ versioned.get('file.txt');
362
+ versioned.get('file.txt', { versionId: 'v1' });
363
+ versioned.delete('file.txt');
364
+ versioned.delete('file.txt', { versionId: 'v1' });
365
+ versioned.getUrl('file.txt', { versionId: 'v1', expiresIn: 600 });
366
+ }
@@ -0,0 +1,66 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * @aws-blocks/bb-file-bucket/middleware
6
+ *
7
+ * Self-registering client middleware for local dev.
8
+ * Hydrates { __blocks: 'file-bucket/download' } and { __blocks: 'file-bucket/upload' }
9
+ * descriptors into live file handles with .download() / .upload() methods.
10
+ */
11
+
12
+ import { registerMiddleware } from '@aws-blocks/core/client';
13
+ import type { FileDownloadClient, FileUploadClient } from './types.js';
14
+
15
+ export type { FileDownloadClient, FileUploadClient };
16
+
17
+ function isDownloadDescriptor(data: unknown): data is { __blocks: 'file-bucket/download'; url: string } {
18
+ if (typeof data !== 'object' || data === null) return false;
19
+ const d = data as Record<string, unknown>;
20
+ return d.__blocks === 'file-bucket/download' && typeof d.url === 'string';
21
+ }
22
+
23
+ function isUploadDescriptor(data: unknown): data is { __blocks: 'file-bucket/upload'; url: string; contentType?: string } {
24
+ if (typeof data !== 'object' || data === null) return false;
25
+ const d = data as Record<string, unknown>;
26
+ return d.__blocks === 'file-bucket/upload' && typeof d.url === 'string';
27
+ }
28
+
29
+ function hydrate(data: unknown): unknown {
30
+ if (isDownloadDescriptor(data)) {
31
+ const { url } = data;
32
+ return {
33
+ async download(): Promise<Blob> {
34
+ const res = await fetch(url);
35
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
36
+ return res.blob();
37
+ },
38
+ getUrl(): string { return url; },
39
+ toJSON() { return { __blocks: 'file-bucket/download' as const, url }; },
40
+ } satisfies FileDownloadClient;
41
+ }
42
+
43
+ if (isUploadDescriptor(data)) {
44
+ const { url, contentType } = data;
45
+ return {
46
+ async upload(body: Blob | File | ArrayBuffer): Promise<void> {
47
+ const headers: Record<string, string> = {};
48
+ if (contentType) headers['Content-Type'] = contentType;
49
+ const res = await fetch(url, { method: 'PUT', body, headers });
50
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
51
+ },
52
+ getUrl(): string { return url; },
53
+ toJSON() { return { __blocks: 'file-bucket/upload' as const, url, contentType }; },
54
+ } satisfies FileUploadClient;
55
+ }
56
+
57
+ if (Array.isArray(data)) return data.map(hydrate);
58
+ if (typeof data === 'object' && data !== null) {
59
+ const result: Record<string, unknown> = {};
60
+ for (const [k, v] of Object.entries(data)) result[k] = hydrate(v);
61
+ return result;
62
+ }
63
+ return data;
64
+ }
65
+
66
+ registerMiddleware({ onResponse: hydrate });
@@ -0,0 +1,66 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * @aws-blocks/bb-file-bucket/mock-middleware
6
+ *
7
+ * Self-registering client middleware for local dev.
8
+ * Hydrates { __blocks: 'file-bucket/download' } and { __blocks: 'file-bucket/upload' }
9
+ * descriptors into live file handles with .download() / .upload() methods.
10
+ */
11
+
12
+ import { registerMiddleware } from '@aws-blocks/core/client';
13
+ import type { FileDownloadClient, FileUploadClient } from './types.js';
14
+
15
+ export type { FileDownloadClient, FileUploadClient };
16
+
17
+ function isDownloadDescriptor(data: unknown): data is { __blocks: 'file-bucket/download'; url: string } {
18
+ if (typeof data !== 'object' || data === null) return false;
19
+ const d = data as Record<string, unknown>;
20
+ return d.__blocks === 'file-bucket/download' && typeof d.url === 'string';
21
+ }
22
+
23
+ function isUploadDescriptor(data: unknown): data is { __blocks: 'file-bucket/upload'; url: string; contentType?: string } {
24
+ if (typeof data !== 'object' || data === null) return false;
25
+ const d = data as Record<string, unknown>;
26
+ return d.__blocks === 'file-bucket/upload' && typeof d.url === 'string';
27
+ }
28
+
29
+ function hydrate(data: unknown): unknown {
30
+ if (isDownloadDescriptor(data)) {
31
+ const { url } = data;
32
+ return {
33
+ async download(): Promise<Blob> {
34
+ const res = await fetch(url);
35
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
36
+ return res.blob();
37
+ },
38
+ getUrl(): string { return url; },
39
+ toJSON() { return { __blocks: 'file-bucket/download' as const, url }; },
40
+ } satisfies FileDownloadClient;
41
+ }
42
+
43
+ if (isUploadDescriptor(data)) {
44
+ const { url, contentType } = data;
45
+ return {
46
+ async upload(body: Blob | File | ArrayBuffer): Promise<void> {
47
+ const headers: Record<string, string> = {};
48
+ if (contentType) headers['Content-Type'] = contentType;
49
+ const res = await fetch(url, { method: 'PUT', body, headers });
50
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
51
+ },
52
+ getUrl(): string { return url; },
53
+ toJSON() { return { __blocks: 'file-bucket/upload' as const, url, contentType }; },
54
+ } satisfies FileUploadClient;
55
+ }
56
+
57
+ if (Array.isArray(data)) return data.map(hydrate);
58
+ if (typeof data === 'object' && data !== null) {
59
+ const result: Record<string, unknown> = {};
60
+ for (const [k, v] of Object.entries(data)) result[k] = hydrate(v);
61
+ return result;
62
+ }
63
+ return data;
64
+ }
65
+
66
+ registerMiddleware({ onResponse: hydrate });
@@ -0,0 +1,31 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { existsSync, realpathSync } from 'node:fs';
5
+ import { resolve, sep } from 'node:path';
6
+
7
+ function blocksError(name: string, message: string): Error {
8
+ const err = new Error(`${name}: ${message}`);
9
+ err.name = name;
10
+ return err;
11
+ }
12
+
13
+ /**
14
+ * Verifies that a resolved file path stays within the given base directory.
15
+ * Prevents both `../` traversal and symlink-based escapes.
16
+ * Throws if the path would escape the base directory.
17
+ *
18
+ * @param baseDir - The trusted root directory that paths must stay within.
19
+ * @param untrustedPath - The user-supplied relative path to validate.
20
+ * @throws {Error} With name `ValidationFailed` if the path escapes the base directory.
21
+ */
22
+ export function assertContainedPath(baseDir: string, untrustedPath: string): void {
23
+ const base = existsSync(baseDir) ? realpathSync(baseDir) : resolve(baseDir);
24
+ const target = resolve(baseDir, untrustedPath);
25
+ if (!target.startsWith(base + sep)) {
26
+ throw blocksError('ValidationFailed', 'Invalid key: path traversal detected');
27
+ }
28
+ if (existsSync(target) && !realpathSync(target).startsWith(base + sep)) {
29
+ throw blocksError('ValidationFailed', 'Invalid key: symlink traversal detected');
30
+ }
31
+ }
@@ -0,0 +1,122 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Tests for path-traversal containment — the mock and dev file-server must
6
+ * reject keys that would escape the bucket's content root. This guard is
7
+ * mock-only (S3 itself accepts `..` in keys), so it is unit-tested here rather
8
+ * than in the cross-environment e2e suite.
9
+ */
10
+
11
+ import { test, describe, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert';
13
+ import { rmSync, existsSync, readdirSync } from 'node:fs';
14
+ import { createServer } from 'node:http';
15
+ import type { Server } from 'node:http';
16
+ import { Scope, isBlocksError } from '@aws-blocks/core';
17
+ import { FileBucket } from './index.mock.js';
18
+ import { attach } from './file-server.js';
19
+ import { mintFileToken, LOCAL_FILE_SECRET } from './tokens.js';
20
+
21
+ const scope = new Scope('pc');
22
+
23
+ function cleanScopeData() {
24
+ const dir = '.bb-data';
25
+ try {
26
+ if (existsSync(dir)) {
27
+ for (const entry of readdirSync(dir)) {
28
+ if (entry.startsWith('pc-')) {
29
+ rmSync(`${dir}/${entry}`, { recursive: true, force: true });
30
+ }
31
+ }
32
+ }
33
+ } catch {}
34
+ }
35
+
36
+ // ── Bucket-level guard (validateKey → assertContainedPath(contentRoot)) ──────
37
+
38
+ describe('path containment: bucket API', () => {
39
+ beforeEach(cleanScopeData);
40
+
41
+ test('put rejects a parent-traversal key', async () => {
42
+ const bucket = new FileBucket(scope, 'pc-put');
43
+ await assert.rejects(
44
+ () => bucket.put('../escape.txt', 'pwned'),
45
+ (err: unknown) => isBlocksError(err, 'ValidationFailed'),
46
+ 'put("../escape.txt") should throw ValidationFailed',
47
+ );
48
+ });
49
+
50
+ test('get rejects a parent-traversal key', async () => {
51
+ const bucket = new FileBucket(scope, 'pc-get');
52
+ await assert.rejects(
53
+ () => bucket.get('../../secret.txt'),
54
+ (err: unknown) => isBlocksError(err, 'ValidationFailed'),
55
+ );
56
+ });
57
+
58
+ test('delete rejects a parent-traversal key', async () => {
59
+ const bucket = new FileBucket(scope, 'pc-del');
60
+ await assert.rejects(
61
+ () => bucket.delete('a/../../escape.txt'),
62
+ (err: unknown) => isBlocksError(err, 'ValidationFailed'),
63
+ );
64
+ });
65
+
66
+ test('a key that escapes only after the content root is rejected', async () => {
67
+ // Naively joining onto dataDir (not contentRoot) would let a single `..`
68
+ // land inside dataDir/ (e.g. the meta/ or versions/ sibling). Validating
69
+ // against the content root rejects it.
70
+ const bucket = new FileBucket(scope, 'pc-sibling');
71
+ await assert.rejects(
72
+ () => bucket.put('../meta/forged.json', 'x'),
73
+ (err: unknown) => isBlocksError(err, 'ValidationFailed'),
74
+ 'a key climbing into a sibling internal root must be rejected',
75
+ );
76
+ });
77
+
78
+ test('legitimate nested keys are NOT false-positives', async () => {
79
+ const bucket = new FileBucket(scope, 'pc-ok');
80
+ await bucket.put('a/b/c/deep.txt', 'fine');
81
+ const file = await bucket.get('a/b/c/deep.txt');
82
+ assert.ok(file);
83
+ assert.strictEqual(file.body.toString(), 'fine');
84
+ });
85
+ });
86
+
87
+ // ── File-server guard (decoded path → assertContainedPath(contentRoot)) ──────
88
+
89
+ describe('path containment: dev file-server', () => {
90
+ let server: Server;
91
+ let port: number;
92
+
93
+ beforeEach(async () => {
94
+ cleanScopeData();
95
+ server = createServer((_req, res) => { res.writeHead(404); res.end(); });
96
+ attach(server);
97
+ port = await new Promise<number>((resolve) => {
98
+ server.listen(0, () => {
99
+ const addr = server.address();
100
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
101
+ });
102
+ });
103
+ });
104
+
105
+ afterEach(() => server.close());
106
+
107
+ test('GET with an encoded ../ traversal returns 400', async () => {
108
+ // Token is minted for the raw (decoded) path; the server decodes the URL
109
+ // before validating containment, so the traversal is caught at the guard.
110
+ const token = mintFileToken('pc-fsrv', '../escape.txt', 'GET', 3600, LOCAL_FILE_SECRET);
111
+ const url = `http://localhost:${port}/.bb-file-bucket/pc-fsrv/..%2Fescape.txt?token=${token}`;
112
+ const res = await fetch(url);
113
+ assert.strictEqual(res.status, 400, `Expected 400 for traversal, got ${res.status}`);
114
+ });
115
+
116
+ test('PUT with an encoded ../ traversal returns 400', async () => {
117
+ const token = mintFileToken('pc-fsrv', '../escape.txt', 'PUT', 3600, LOCAL_FILE_SECRET, 'text/plain');
118
+ const url = `http://localhost:${port}/.bb-file-bucket/pc-fsrv/..%2Fescape.txt?token=${token}`;
119
+ const res = await fetch(url, { method: 'PUT', body: 'pwned', headers: { 'Content-Type': 'text/plain' } });
120
+ assert.strictEqual(res.status, 400, `Expected 400 for traversal, got ${res.status}`);
121
+ });
122
+ });