@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
package/src/paths.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * On-disk layout for the FileBucket mock.
6
+ *
7
+ * Internal data (metadata, version history) is segregated into sibling roots
8
+ * so it can never collide with user keys:
9
+ *
10
+ * ```
11
+ * .bb-data/{fullId}/content/{key} file body (byte-identical)
12
+ * .bb-data/{fullId}/meta/{key}.json sidecar metadata
13
+ * .bb-data/{fullId}/versions/{key}/{versionId} version body
14
+ * .bb-data/{fullId}/versions/{key}/{versionId}.json version metadata
15
+ * .bb-data/{fullId}/versions/{key}/__deleted__ delete marker (sentinel)
16
+ * ```
17
+ *
18
+ * Because user content lives only under `content/`, `scan()` can yield every
19
+ * file it finds with no marker-based filtering — a user key like
20
+ * `data.__meta__.json` or `logs/__versions__/x` is stored verbatim and is
21
+ * never confused with internal bookkeeping.
22
+ *
23
+ * Both the mock (`index.mock.ts`) and the dev file-server (`file-server.ts`)
24
+ * import these helpers so the two stay in lockstep.
25
+ */
26
+
27
+ import { join } from 'node:path';
28
+
29
+ /** Subdirectory holding file content, byte-identical to what was written. */
30
+ export const CONTENT_DIR = 'content';
31
+ /** Subdirectory holding sidecar metadata JSON. */
32
+ export const META_DIR = 'meta';
33
+ /** Subdirectory holding version history, one directory per key. */
34
+ export const VERSIONS_DIR = 'versions';
35
+ /** Sentinel filename marking a versioned key as deleted. */
36
+ export const DELETE_MARKER = '__deleted__';
37
+ /** Suffix for version metadata sidecars (internal namespace only). */
38
+ const VERSION_META_SUFFIX = '.json';
39
+
40
+ /** Root directory under which all user content is stored. */
41
+ export function contentRoot(root: string): string {
42
+ return join(root, CONTENT_DIR);
43
+ }
44
+
45
+ /** Absolute path to a file's content. */
46
+ export function contentPath(root: string, key: string): string {
47
+ return join(root, CONTENT_DIR, key);
48
+ }
49
+
50
+ /** Absolute path to a file's sidecar metadata. */
51
+ export function metaPath(root: string, key: string): string {
52
+ return join(root, META_DIR, key + '.json');
53
+ }
54
+
55
+ /** Directory holding all versions of a single key. */
56
+ export function versionsDirFor(root: string, key: string): string {
57
+ return join(root, VERSIONS_DIR, key);
58
+ }
59
+
60
+ /** Absolute path to a specific version's body. */
61
+ export function versionContentPath(root: string, key: string, versionId: string): string {
62
+ return join(root, VERSIONS_DIR, key, versionId);
63
+ }
64
+
65
+ /** Absolute path to a specific version's metadata sidecar. */
66
+ export function versionMetaPath(root: string, key: string, versionId: string): string {
67
+ return join(root, VERSIONS_DIR, key, versionId + VERSION_META_SUFFIX);
68
+ }
69
+
70
+ /** Absolute path to a key's delete-marker sentinel. */
71
+ export function deleteMarkerPath(root: string, key: string): string {
72
+ return join(root, VERSIONS_DIR, key, DELETE_MARKER);
73
+ }
74
+
75
+ /** True if a versions-directory entry is a real version body (not metadata or a sentinel). */
76
+ export function isVersionEntry(entry: string): boolean {
77
+ return entry !== DELETE_MARKER && !entry.endsWith(VERSION_META_SUFFIX);
78
+ }
@@ -0,0 +1,137 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Tests for scan() correctness — ensures user files are never silently
6
+ * excluded by internal sidecar/version-directory filtering.
7
+ */
8
+
9
+ import { test, describe, beforeEach } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { rmSync, existsSync, readdirSync } from 'node:fs';
12
+ import { Scope } from '@aws-blocks/core';
13
+ import { FileBucket } from './index.mock.js';
14
+
15
+ const scope = new Scope('scn');
16
+
17
+ beforeEach(() => {
18
+ const dir = '.bb-data';
19
+ try {
20
+ if (existsSync(dir)) {
21
+ for (const entry of readdirSync(dir)) {
22
+ if (entry.startsWith('scn-')) {
23
+ rmSync(`${dir}/${entry}`, { recursive: true, force: true });
24
+ }
25
+ }
26
+ }
27
+ } catch {}
28
+ });
29
+
30
+ // ── Filenames containing internal marker substrings ─────────────────────────
31
+
32
+ describe('scan: user files with __versions__ in name', () => {
33
+ test('file named "my__versions__backup.txt" is listed', async () => {
34
+ const bucket = new FileBucket(scope, 'scan-v1');
35
+ await bucket.put('my__versions__backup.txt', 'data');
36
+ await bucket.put('normal.txt', 'data');
37
+
38
+ const files: string[] = [];
39
+ for await (const f of bucket.scan()) files.push(f.path);
40
+
41
+ assert.ok(
42
+ files.includes('my__versions__backup.txt'),
43
+ `scan() should include user file with __versions__ in name. Got: ${JSON.stringify(files)}`,
44
+ );
45
+ });
46
+
47
+ test('file in directory containing __versions__ is listed', async () => {
48
+ const bucket = new FileBucket(scope, 'scan-v2');
49
+ await bucket.put('logs/__versions__report/data.csv', 'csv');
50
+
51
+ const files: string[] = [];
52
+ for await (const f of bucket.scan()) files.push(f.path);
53
+
54
+ assert.ok(
55
+ files.includes('logs/__versions__report/data.csv'),
56
+ `scan() should include file in __versions__-named directory. Got: ${JSON.stringify(files)}`,
57
+ );
58
+ });
59
+
60
+ test('internal version directories are still excluded', async () => {
61
+ const bucket = new FileBucket(scope, 'scan-v4', { versioned: true });
62
+ await bucket.put('doc.txt', 'v1');
63
+ await bucket.put('doc.txt', 'v2');
64
+
65
+ const files: string[] = [];
66
+ for await (const f of bucket.scan()) files.push(f.path);
67
+
68
+ assert.deepStrictEqual(files, ['doc.txt']);
69
+ });
70
+ });
71
+
72
+ describe('scan: user files with .__meta__.json in name', () => {
73
+ test('file named "data.__meta__.json" is listed', async () => {
74
+ const bucket = new FileBucket(scope, 'scan-m1');
75
+ await bucket.put('data.__meta__.json', '{"user": "file"}');
76
+ await bucket.put('normal.txt', 'data');
77
+
78
+ const files: string[] = [];
79
+ for await (const f of bucket.scan()) files.push(f.path);
80
+
81
+ assert.ok(
82
+ files.includes('data.__meta__.json'),
83
+ `scan() should include user file named "data.__meta__.json". Got: ${JSON.stringify(files)}`,
84
+ );
85
+ });
86
+
87
+ test('actual sidecar metadata files are excluded', async () => {
88
+ const bucket = new FileBucket(scope, 'scan-m4');
89
+ await bucket.put('report.pdf', 'pdf', { contentType: 'application/pdf' });
90
+
91
+ const files: string[] = [];
92
+ for await (const f of bucket.scan()) files.push(f.path);
93
+
94
+ // Only the real file, not its sidecar
95
+ assert.deepStrictEqual(files, ['report.pdf']);
96
+ });
97
+
98
+ test('user file and its own sidecar coexist correctly', async () => {
99
+ // A user file named "data.__meta__.json" is stored verbatim under the
100
+ // content root; its metadata lives in the segregated meta root, so there
101
+ // is no collision and scan() lists exactly the user file.
102
+ const bucket = new FileBucket(scope, 'scan-m5');
103
+ await bucket.put('data.__meta__.json', 'user content', { contentType: 'application/json' });
104
+
105
+ const file = await bucket.get('data.__meta__.json');
106
+ assert.ok(file);
107
+ assert.strictEqual(file.body.toString(), 'user content');
108
+ assert.strictEqual(file.contentType, 'application/json');
109
+
110
+ const files: string[] = [];
111
+ for await (const f of bucket.scan()) files.push(f.path);
112
+
113
+ assert.deepStrictEqual(files, ['data.__meta__.json']);
114
+ });
115
+ });
116
+
117
+ describe('scan: segregated on-disk layout', () => {
118
+ test('content, meta, and versions live under separate roots', async () => {
119
+ const bucket = new FileBucket(scope, 'scan-layout', { versioned: true });
120
+ await bucket.put('docs/report.pdf', 'pdf', { contentType: 'application/pdf' });
121
+
122
+ const base = `.bb-data/scn-scan-layout`;
123
+ // User content is byte-identical under content/
124
+ assert.ok(existsSync(`${base}/content/docs/report.pdf`), 'content/ should hold the file body');
125
+ // Metadata is segregated under meta/
126
+ assert.ok(existsSync(`${base}/meta/docs/report.pdf.json`), 'meta/ should hold the sidecar');
127
+ // Versions are segregated under versions/{key}/
128
+ assert.ok(existsSync(`${base}/versions/docs/report.pdf/v1`), 'versions/ should hold version bodies');
129
+
130
+ // scan() only ever sees the content root — exactly one user file.
131
+ const files: string[] = [];
132
+ for await (const f of bucket.scan()) files.push(f.path);
133
+ assert.deepStrictEqual(files, ['docs/report.pdf']);
134
+ });
135
+ });
136
+
137
+
package/src/tokens.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { createHmac } from 'node:crypto';
5
+ import { constantTimeEquals } from '@aws-blocks/core/bb-utils';
6
+
7
+ // ── Token helpers ───────────────────────────────────────────────────────────
8
+
9
+ export const LOCAL_FILE_SECRET = '__blocks_file_bucket_dev_secret__';
10
+
11
+ interface FileTokenPayload {
12
+ fullId: string;
13
+ path: string;
14
+ method: 'GET' | 'PUT';
15
+ contentType?: string;
16
+ exp: number;
17
+ }
18
+
19
+ export function mintFileToken(
20
+ fullId: string,
21
+ path: string,
22
+ method: 'GET' | 'PUT',
23
+ expiresIn: number,
24
+ secret: string,
25
+ contentType?: string,
26
+ ): string {
27
+ const payload: FileTokenPayload = {
28
+ fullId,
29
+ path,
30
+ method,
31
+ exp: Math.floor(Date.now() / 1000) + expiresIn,
32
+ ...(contentType ? { contentType } : {}),
33
+ };
34
+ const json = JSON.stringify(payload);
35
+ const sig = createHmac('sha256', secret).update(json).digest('base64url');
36
+ return `${Buffer.from(json).toString('base64url')}.${sig}`;
37
+ }
38
+
39
+ export function validateFileToken(
40
+ token: string,
41
+ secret: string,
42
+ expectedFullId: string,
43
+ expectedPath: string,
44
+ expectedMethod: 'GET' | 'PUT',
45
+ ): FileTokenPayload | null {
46
+ try {
47
+ const [payloadB64, sig] = token.split('.');
48
+ if (!payloadB64 || !sig) return null;
49
+ const json = Buffer.from(payloadB64, 'base64url').toString();
50
+ const expectedSig = createHmac('sha256', secret).update(json).digest('base64url');
51
+ if (!constantTimeEquals(sig, expectedSig)) return null;
52
+ const payload: FileTokenPayload = JSON.parse(json);
53
+ if (payload.exp < Math.floor(Date.now() / 1000)) return null;
54
+ if (payload.fullId !== expectedFullId) return null;
55
+ if (payload.path !== expectedPath) return null;
56
+ if (payload.method !== expectedMethod) return null;
57
+ return payload;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
package/src/types.ts ADDED
@@ -0,0 +1,206 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Shared types for FileBucket. Imported by mock, aws, and browser entry points.
6
+ * This file has zero runtime dependencies — types only.
7
+ */
8
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
9
+
10
+ // ── Constructor options ─────────────────────────────────────────────────────
11
+
12
+ export interface FileBucketOptions {
13
+ /** Enable object versioning. Default: false. */
14
+ versioned?: boolean;
15
+ /** CORS rules for browser-based access. */
16
+ corsRules?: CorsRule[];
17
+ /** Lifecycle rules for automatic object expiration or transitions. */
18
+ lifecycleRules?: LifecycleRule[];
19
+ /** Wrap an existing S3 bucket instead of creating one. */
20
+ bucket?: ExternalBucketRef;
21
+ /**
22
+ * CDK removal behavior for the underlying S3 bucket. When omitted,
23
+ * CDK's default applies (RETAIN — the bucket and its contents are
24
+ * preserved on `cdk destroy`).
25
+ *
26
+ * Pass `'destroy'` for sandbox / ephemeral stacks where the bucket
27
+ * should be dropped on teardown. This also enables `autoDeleteObjects`
28
+ * so CloudFormation can empty the bucket before deletion — required
29
+ * since S3 rejects DELETE on a non-empty bucket.
30
+ *
31
+ * Pass `'retain'` to set the policy explicitly (identical to omitting
32
+ * it today, but robust against stack-layer policy overrides).
33
+ *
34
+ * Templates that apply `RemovalPolicies.of(stack).destroy()` at the
35
+ * top level override this setting.
36
+ *
37
+ * Ignored by the mock and browser runtimes (no AWS resource to retain).
38
+ */
39
+ removalPolicy?: 'destroy' | 'retain';
40
+ /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
41
+ logger?: ChildLogger;
42
+ }
43
+
44
+ // ── Method options ──────────────────────────────────────────────────────────
45
+
46
+ export interface PutOptions {
47
+ /** MIME type of the file (e.g., `image/png`). */
48
+ contentType?: string;
49
+ /** Custom metadata key-value pairs. */
50
+ metadata?: Record<string, string>;
51
+ /** Cache-Control header value. */
52
+ cacheControl?: string;
53
+ }
54
+
55
+ export interface GetUrlOptions {
56
+ /** URL expiration in seconds. Default: 3600. */
57
+ expiresIn?: number;
58
+ }
59
+
60
+ export interface PutUrlOptions {
61
+ /** URL expiration in seconds. Default: 3600. */
62
+ expiresIn?: number;
63
+ /** Required content type for the upload. */
64
+ contentType?: string;
65
+ }
66
+
67
+ export interface ScanOptions {
68
+ /** Only list files whose keys start with this prefix. */
69
+ prefix?: string;
70
+ }
71
+
72
+ // ── Versioning options (extend base options with versionId) ─────────────────
73
+
74
+ /** Options for get on versioned buckets. */
75
+ export interface VersionedGetOptions {
76
+ /** Retrieve a specific version. Omit to get the latest. */
77
+ versionId?: string;
78
+ }
79
+
80
+ /** Options for delete on versioned buckets. */
81
+ export interface VersionedDeleteOptions {
82
+ /** Delete a specific version permanently. Omit to place a delete marker. */
83
+ versionId?: string;
84
+ }
85
+
86
+ /** Options for getUrl/getFileHandle on versioned buckets. */
87
+ export interface VersionedGetUrlOptions extends GetUrlOptions {
88
+ /** Generate URL for a specific version. */
89
+ versionId?: string;
90
+ }
91
+
92
+ // ── Conditional type helpers ────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Resolves the get options type based on whether versioning is enabled.
96
+ * Versioned buckets accept `{ versionId }`, non-versioned accept no options.
97
+ */
98
+ export type GetOptionsFor<O extends FileBucketOptions> =
99
+ O extends { versioned: true } ? VersionedGetOptions : undefined;
100
+
101
+ /**
102
+ * Resolves the delete options type based on whether versioning is enabled.
103
+ * Versioned buckets accept `{ versionId }`, non-versioned accept no options.
104
+ */
105
+ export type DeleteOptionsFor<O extends FileBucketOptions> =
106
+ O extends { versioned: true } ? VersionedDeleteOptions : undefined;
107
+
108
+ /**
109
+ * Resolves the getUrl/getFileHandle options type based on whether versioning is enabled.
110
+ * Versioned buckets accept `{ expiresIn, versionId }`, non-versioned accept `{ expiresIn }`.
111
+ */
112
+ export type GetUrlOptionsFor<O extends FileBucketOptions> =
113
+ O extends { versioned: true } ? VersionedGetUrlOptions : GetUrlOptions;
114
+
115
+ // ── Return types ────────────────────────────────────────────────────────────
116
+
117
+ export interface FileContent {
118
+ /** The file body as a Buffer. */
119
+ body: Buffer;
120
+ /** MIME type of the file. */
121
+ contentType: string;
122
+ /** Custom metadata key-value pairs. */
123
+ metadata: Record<string, string>;
124
+ /** File size in bytes. */
125
+ size: number;
126
+ }
127
+
128
+ export interface FileInfo {
129
+ /** The object key. */
130
+ path: string;
131
+ /** File size in bytes. */
132
+ size: number;
133
+ /** Last modification timestamp. */
134
+ lastModified: Date;
135
+ }
136
+
137
+ /** Metadata for a single object version. */
138
+ export interface FileVersionInfo {
139
+ /** The version identifier. */
140
+ versionId: string;
141
+ /** When this version was created. */
142
+ lastModified: Date;
143
+ /** Size in bytes. */
144
+ size: number;
145
+ /** Whether this is the current (latest) version. */
146
+ isCurrent: boolean;
147
+ }
148
+
149
+ // ── Infrastructure types ────────────────────────────────────────────────────
150
+
151
+ export interface CorsRule {
152
+ allowedOrigins: string[];
153
+ allowedMethods: ('GET' | 'PUT' | 'POST' | 'DELETE' | 'HEAD')[];
154
+ allowedHeaders?: string[];
155
+ exposedHeaders?: string[];
156
+ maxAge?: number;
157
+ }
158
+
159
+ export interface LifecycleRule {
160
+ /** Prefix filter for the rule. */
161
+ prefix?: string;
162
+ /** Days after creation to expire objects. */
163
+ expirationDays?: number;
164
+ /** Days after creation to transition to Infrequent Access. */
165
+ transitionToIaDays?: number;
166
+ }
167
+
168
+ export interface ExternalBucketRef {
169
+ readonly __brand: 'ExternalBucketRef';
170
+ readonly bucketName: string;
171
+ }
172
+
173
+ // ── Client handle types (hydrated by middleware) ────────────────────────────
174
+
175
+ /** Client-side download handle. Works server-side and serializes via toJSON() for the wire. */
176
+ export interface FileDownloadClient {
177
+ /** Download the file as a Blob (server) or via fetch (client). */
178
+ download(): Promise<Blob>;
179
+ /** Get the presigned URL. */
180
+ getUrl(): string;
181
+ /** @internal Transferable serialization — called automatically by JSON.stringify(). */
182
+ toJSON(): FileDownloadDescriptor;
183
+ }
184
+
185
+ /** Client-side upload handle. Works server-side and serializes via toJSON() for the wire. */
186
+ export interface FileUploadClient {
187
+ /** Upload a file body to the presigned URL. */
188
+ upload(body: Blob | File | ArrayBuffer): Promise<void>;
189
+ /** Get the presigned URL. */
190
+ getUrl(): string;
191
+ /** @internal Transferable serialization — called automatically by JSON.stringify(). */
192
+ toJSON(): FileUploadDescriptor;
193
+ }
194
+
195
+ // ── Wire descriptor types ───────────────────────────────────────────────────
196
+
197
+ export interface FileDownloadDescriptor {
198
+ readonly __blocks: 'file-bucket/download';
199
+ readonly url: string;
200
+ }
201
+
202
+ export interface FileUploadDescriptor {
203
+ readonly __blocks: 'file-bucket/upload';
204
+ readonly url: string;
205
+ readonly contentType?: string;
206
+ }
@@ -0,0 +1,120 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Tests for presigned URL generation — ensures URLs are valid and parseable
6
+ * for paths containing special characters (#, ?, %, spaces).
7
+ */
8
+
9
+ import { test, describe, beforeEach } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { rmSync, existsSync, readdirSync } from 'node:fs';
12
+ import { Scope } from '@aws-blocks/core';
13
+ import { FileBucket } from './index.mock.js';
14
+
15
+ const scope = new Scope('enc');
16
+
17
+ beforeEach(() => {
18
+ const dir = '.bb-data';
19
+ try {
20
+ if (existsSync(dir)) {
21
+ for (const entry of readdirSync(dir)) {
22
+ if (entry.startsWith('enc-')) {
23
+ rmSync(`${dir}/${entry}`, { recursive: true, force: true });
24
+ }
25
+ }
26
+ }
27
+ } catch {}
28
+ });
29
+
30
+ describe('getUrl: URL encoding', () => {
31
+ test('path with # produces valid URL (# encoded as %23)', async () => {
32
+ const bucket = new FileBucket(scope, 'url-hash');
33
+ const url = await bucket.getUrl('file#1.txt');
34
+ const parsed = new URL(url);
35
+
36
+ assert.strictEqual(parsed.hash, '', 'Hash fragment should be empty — # must be encoded');
37
+ assert.ok(parsed.searchParams.has('token'), 'Token must be in query params, not lost in fragment');
38
+ });
39
+
40
+ test('path with ? produces valid URL (? encoded as %3F)', async () => {
41
+ const bucket = new FileBucket(scope, 'url-question');
42
+ const url = await bucket.getUrl('what?file.txt');
43
+ const parsed = new URL(url);
44
+
45
+ assert.ok(parsed.searchParams.has('token'), 'Token must be accessible in query params');
46
+ assert.ok(
47
+ !parsed.pathname.endsWith('what'),
48
+ 'Pathname should not be truncated at the ? character',
49
+ );
50
+ });
51
+
52
+ test('path with % literal produces valid URL (% encoded as %25)', async () => {
53
+ const bucket = new FileBucket(scope, 'url-percent');
54
+ const url = await bucket.getUrl('100%done.txt');
55
+ const parsed = new URL(url);
56
+
57
+ assert.ok(parsed.searchParams.has('token'), 'Token must be in query params');
58
+ // % must be encoded as %25 to avoid ambiguity
59
+ assert.ok(
60
+ url.includes('100%25done.txt'),
61
+ `URL should encode % as %25. Got: ${url}`,
62
+ );
63
+ });
64
+
65
+ test('already-encoded path is not double-encoded', async () => {
66
+ // A file literally named "file%231.txt" (contains percent-two-three)
67
+ // should encode to "file%25231.txt" — the % becomes %25
68
+ const bucket = new FileBucket(scope, 'url-double');
69
+ const url = await bucket.getUrl('file%231.txt');
70
+ const parsed = new URL(url);
71
+
72
+ assert.ok(parsed.searchParams.has('token'), 'Token must be in query params');
73
+ assert.ok(
74
+ url.includes('file%25231.txt'),
75
+ `Literal % in filename should be encoded to %25. Got: ${url}`,
76
+ );
77
+ });
78
+ });
79
+
80
+ describe('putUrl: URL encoding', () => {
81
+ test('path with # produces valid URL', async () => {
82
+ const bucket = new FileBucket(scope, 'put-hash');
83
+ const url = await bucket.putUrl('upload#1.bin');
84
+ const parsed = new URL(url);
85
+
86
+ assert.strictEqual(parsed.hash, '', 'Hash fragment should be empty');
87
+ assert.ok(parsed.searchParams.has('token'), 'Token must be in query params');
88
+ });
89
+ });
90
+
91
+ describe('getUrl: versioned with special chars', () => {
92
+ test('versionId param preserved alongside encoded path', async () => {
93
+ const bucket = new FileBucket(scope, 'url-ver', { versioned: true });
94
+ await bucket.put('file#1.txt', 'v1');
95
+ await bucket.put('file#1.txt', 'v2');
96
+
97
+ const versions = await bucket.listVersions('file#1.txt');
98
+ const oldest = versions[versions.length - 1];
99
+ const url = await bucket.getUrl('file#1.txt', { versionId: oldest.versionId });
100
+ const parsed = new URL(url);
101
+
102
+ assert.ok(parsed.searchParams.has('token'), 'Token in query');
103
+ assert.strictEqual(parsed.searchParams.get('versionId'), oldest.versionId);
104
+ assert.strictEqual(parsed.hash, '', 'No fragment');
105
+ });
106
+ });
107
+
108
+ describe('FileBucketErrors completeness', () => {
109
+ test('FileBucketErrors includes FileTooLarge', async () => {
110
+ const { FileBucketErrors } = await import('./errors.js');
111
+ assert.ok(
112
+ 'FileTooLarge' in FileBucketErrors,
113
+ `FileBucketErrors should include FileTooLarge. Keys: ${Object.keys(FileBucketErrors)}`,
114
+ );
115
+ assert.strictEqual(
116
+ (FileBucketErrors as any).FileTooLarge,
117
+ 'EntityTooLarge',
118
+ );
119
+ });
120
+ });
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
+ export const BB_NAME = 'FileBucket';
3
+ export const BB_VERSION = '0.1.0';