@superblocksteam/shared 0.9613.2 → 0.9614.1
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/dist/database-lifecycle/destructiveMigrations.d.ts +18 -0
- package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.js.map +1 -1
- package/dist/database-lifecycle/index.d.ts +8 -1
- package/dist/database-lifecycle/index.d.ts.map +1 -1
- package/dist/database-lifecycle/index.js +2 -1
- package/dist/database-lifecycle/index.js.map +1 -1
- package/dist/dbfs/types.d.ts +1 -0
- package/dist/dbfs/types.d.ts.map +1 -1
- package/dist/dbfs/types.js +14 -3
- package/dist/dbfs/types.js.map +1 -1
- package/dist/errors/http.d.ts +5 -3
- package/dist/errors/http.d.ts.map +1 -1
- package/dist/errors/http.js +9 -5
- package/dist/errors/http.js.map +1 -1
- package/dist/types/ai/index.d.ts +1 -0
- package/dist/types/ai/index.d.ts.map +1 -1
- package/dist/types/ai/index.js +1 -0
- package/dist/types/ai/index.js.map +1 -1
- package/dist/types/audit/ocsf.d.ts +11 -0
- package/dist/types/audit/ocsf.d.ts.map +1 -1
- package/dist/types/billing/index.d.ts +1 -0
- package/dist/types/billing/index.d.ts.map +1 -1
- package/dist/types/billing/index.js +1 -0
- package/dist/types/billing/index.js.map +1 -1
- package/dist/types/billing/usageSource.d.ts +26 -0
- package/dist/types/billing/usageSource.d.ts.map +1 -0
- package/dist/types/billing/usageSource.js +100 -0
- package/dist/types/billing/usageSource.js.map +1 -0
- package/dist/types/policyGate/errors.d.ts +3 -0
- package/dist/types/policyGate/errors.d.ts.map +1 -1
- package/dist/types/policyGate/errors.js +1 -0
- package/dist/types/policyGate/errors.js.map +1 -1
- package/dist/types/policyGate/index.d.ts +5 -1
- package/dist/types/policyGate/index.d.ts.map +1 -1
- package/dist/types/policyGate/index.js +11 -0
- package/dist/types/policyGate/index.js.map +1 -1
- package/dist/types/policyGate/index.test.d.ts +2 -0
- package/dist/types/policyGate/index.test.d.ts.map +1 -0
- package/dist/types/policyGate/index.test.js +23 -0
- package/dist/types/policyGate/index.test.js.map +1 -0
- package/dist/types/publishPipeline/index.d.ts +27 -0
- package/dist/types/publishPipeline/index.d.ts.map +1 -1
- package/dist/types/publishPipeline/index.js +1 -0
- package/dist/types/publishPipeline/index.js.map +1 -1
- package/dist/types/publishPipeline/plan.d.ts +9 -4
- package/dist/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist/types/publishPipeline/plan.js +37 -20
- package/dist/types/publishPipeline/plan.js.map +1 -1
- package/dist/types/publishPipeline/plan.test.js +28 -8
- package/dist/types/publishPipeline/plan.test.js.map +1 -1
- package/dist/types/publishPipeline/policyStatus.d.ts +7 -0
- package/dist/types/publishPipeline/policyStatus.d.ts.map +1 -0
- package/dist/types/publishPipeline/policyStatus.js +40 -0
- package/dist/types/publishPipeline/policyStatus.js.map +1 -0
- package/dist/types/reviewPolicy/index.d.ts +14 -4
- package/dist/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist/types/reviewPolicy/index.js +11 -5
- package/dist/types/reviewPolicy/index.js.map +1 -1
- package/dist/types/scim/index.d.ts +21 -1
- package/dist/types/scim/index.d.ts.map +1 -1
- package/dist/types/scim/index.js +19 -1
- package/dist/types/scim/index.js.map +1 -1
- package/dist/utils/attachment-delivery.d.ts +7 -0
- package/dist/utils/attachment-delivery.d.ts.map +1 -1
- package/dist/utils/attachment-delivery.js +19 -2
- package/dist/utils/attachment-delivery.js.map +1 -1
- package/dist/utils/attachment-delivery.test.d.ts +2 -0
- package/dist/utils/attachment-delivery.test.d.ts.map +1 -0
- package/dist/utils/attachment-delivery.test.js +40 -0
- package/dist/utils/attachment-delivery.test.js.map +1 -0
- package/dist/utils/attachment-upload.d.ts +3 -2
- package/dist/utils/attachment-upload.d.ts.map +1 -1
- package/dist/utils/attachment-upload.js +11 -3
- package/dist/utils/attachment-upload.js.map +1 -1
- package/dist/utils/attachment-upload.test.js +40 -1
- package/dist/utils/attachment-upload.test.js.map +1 -1
- package/dist/utils/attachment-validation.d.ts +64 -0
- package/dist/utils/attachment-validation.d.ts.map +1 -0
- package/dist/utils/attachment-validation.js +269 -0
- package/dist/utils/attachment-validation.js.map +1 -0
- package/dist/utils/attachment-validation.test.d.ts +2 -0
- package/dist/utils/attachment-validation.test.d.ts.map +1 -0
- package/dist/utils/attachment-validation.test.js +171 -0
- package/dist/utils/attachment-validation.test.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +18 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -1
- package/dist-esm/database-lifecycle/index.d.ts +8 -1
- package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/index.js +1 -0
- package/dist-esm/database-lifecycle/index.js.map +1 -1
- package/dist-esm/dbfs/types.d.ts +1 -0
- package/dist-esm/dbfs/types.d.ts.map +1 -1
- package/dist-esm/dbfs/types.js +13 -2
- package/dist-esm/dbfs/types.js.map +1 -1
- package/dist-esm/errors/http.d.ts +5 -3
- package/dist-esm/errors/http.d.ts.map +1 -1
- package/dist-esm/errors/http.js +9 -5
- package/dist-esm/errors/http.js.map +1 -1
- package/dist-esm/types/ai/index.d.ts +1 -0
- package/dist-esm/types/ai/index.d.ts.map +1 -1
- package/dist-esm/types/ai/index.js +1 -0
- package/dist-esm/types/ai/index.js.map +1 -1
- package/dist-esm/types/audit/ocsf.d.ts +11 -0
- package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
- package/dist-esm/types/billing/index.d.ts +1 -0
- package/dist-esm/types/billing/index.d.ts.map +1 -1
- package/dist-esm/types/billing/index.js +1 -0
- package/dist-esm/types/billing/index.js.map +1 -1
- package/dist-esm/types/billing/usageSource.d.ts +26 -0
- package/dist-esm/types/billing/usageSource.d.ts.map +1 -0
- package/dist-esm/types/billing/usageSource.js +97 -0
- package/dist-esm/types/billing/usageSource.js.map +1 -0
- package/dist-esm/types/policyGate/errors.d.ts +3 -0
- package/dist-esm/types/policyGate/errors.d.ts.map +1 -1
- package/dist-esm/types/policyGate/errors.js +1 -0
- package/dist-esm/types/policyGate/errors.js.map +1 -1
- package/dist-esm/types/policyGate/index.d.ts +5 -1
- package/dist-esm/types/policyGate/index.d.ts.map +1 -1
- package/dist-esm/types/policyGate/index.js +10 -0
- package/dist-esm/types/policyGate/index.js.map +1 -1
- package/dist-esm/types/policyGate/index.test.d.ts +2 -0
- package/dist-esm/types/policyGate/index.test.d.ts.map +1 -0
- package/dist-esm/types/policyGate/index.test.js +21 -0
- package/dist-esm/types/policyGate/index.test.js.map +1 -0
- package/dist-esm/types/publishPipeline/index.d.ts +27 -0
- package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/index.js +1 -0
- package/dist-esm/types/publishPipeline/index.js.map +1 -1
- package/dist-esm/types/publishPipeline/plan.d.ts +9 -4
- package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/plan.js +36 -19
- package/dist-esm/types/publishPipeline/plan.js.map +1 -1
- package/dist-esm/types/publishPipeline/plan.test.js +29 -9
- package/dist-esm/types/publishPipeline/plan.test.js.map +1 -1
- package/dist-esm/types/publishPipeline/policyStatus.d.ts +7 -0
- package/dist-esm/types/publishPipeline/policyStatus.d.ts.map +1 -0
- package/dist-esm/types/publishPipeline/policyStatus.js +37 -0
- package/dist-esm/types/publishPipeline/policyStatus.js.map +1 -0
- package/dist-esm/types/reviewPolicy/index.d.ts +14 -4
- package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist-esm/types/reviewPolicy/index.js +10 -4
- package/dist-esm/types/reviewPolicy/index.js.map +1 -1
- package/dist-esm/types/scim/index.d.ts +21 -1
- package/dist-esm/types/scim/index.d.ts.map +1 -1
- package/dist-esm/types/scim/index.js +18 -0
- package/dist-esm/types/scim/index.js.map +1 -1
- package/dist-esm/utils/attachment-delivery.d.ts +7 -0
- package/dist-esm/utils/attachment-delivery.d.ts.map +1 -1
- package/dist-esm/utils/attachment-delivery.js +17 -2
- package/dist-esm/utils/attachment-delivery.js.map +1 -1
- package/dist-esm/utils/attachment-delivery.test.d.ts +2 -0
- package/dist-esm/utils/attachment-delivery.test.d.ts.map +1 -0
- package/dist-esm/utils/attachment-delivery.test.js +38 -0
- package/dist-esm/utils/attachment-delivery.test.js.map +1 -0
- package/dist-esm/utils/attachment-upload.d.ts +3 -2
- package/dist-esm/utils/attachment-upload.d.ts.map +1 -1
- package/dist-esm/utils/attachment-upload.js +10 -2
- package/dist-esm/utils/attachment-upload.js.map +1 -1
- package/dist-esm/utils/attachment-upload.test.js +42 -3
- package/dist-esm/utils/attachment-upload.test.js.map +1 -1
- package/dist-esm/utils/attachment-validation.d.ts +64 -0
- package/dist-esm/utils/attachment-validation.d.ts.map +1 -0
- package/dist-esm/utils/attachment-validation.js +259 -0
- package/dist-esm/utils/attachment-validation.js.map +1 -0
- package/dist-esm/utils/attachment-validation.test.d.ts +2 -0
- package/dist-esm/utils/attachment-validation.test.d.ts.map +1 -0
- package/dist-esm/utils/attachment-validation.test.js +169 -0
- package/dist-esm/utils/attachment-validation.test.js.map +1 -0
- package/dist-esm/utils/index.d.ts +1 -0
- package/dist-esm/utils/index.d.ts.map +1 -1
- package/dist-esm/utils/index.js +1 -0
- package/dist-esm/utils/index.js.map +1 -1
- package/package.json +4 -3
- package/src/database-lifecycle/destructiveMigrations.ts +19 -0
- package/src/database-lifecycle/index.ts +13 -1
- package/src/dbfs/types.ts +15 -3
- package/src/errors/http.ts +11 -5
- package/src/types/ai/index.ts +1 -0
- package/src/types/audit/ocsf.ts +14 -0
- package/src/types/billing/index.ts +1 -0
- package/src/types/billing/usageSource.ts +114 -0
- package/src/types/policyGate/errors.ts +1 -0
- package/src/types/policyGate/index.test.ts +25 -0
- package/src/types/policyGate/index.ts +21 -1
- package/src/types/publishPipeline/index.ts +29 -0
- package/src/types/publishPipeline/plan.test.ts +34 -9
- package/src/types/publishPipeline/plan.ts +41 -19
- package/src/types/publishPipeline/policyStatus.ts +44 -0
- package/src/types/reviewPolicy/index.ts +18 -4
- package/src/types/scim/index.ts +28 -1
- package/src/utils/attachment-delivery.test.ts +52 -0
- package/src/utils/attachment-delivery.ts +20 -2
- package/src/utils/attachment-upload.test.ts +58 -4
- package/src/utils/attachment-upload.ts +13 -3
- package/src/utils/attachment-validation.test.ts +226 -0
- package/src/utils/attachment-validation.ts +269 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { PDFDocument } from 'pdf-lib';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AttachmentValidationError,
|
|
6
|
+
MAX_ARCHIVE_ATTACHMENT_BYTES,
|
|
7
|
+
MAX_IMAGE_ATTACHMENT_BYTES,
|
|
8
|
+
MAX_PDF_ATTACHMENT_BYTES,
|
|
9
|
+
MAX_TEXT_ATTACHMENT_BYTES,
|
|
10
|
+
assertAttachmentSize,
|
|
11
|
+
getAttachmentLimitsHelp,
|
|
12
|
+
getAttachmentUploadPolicy,
|
|
13
|
+
getAttachmentUploadLimit,
|
|
14
|
+
validateAttachmentBytes
|
|
15
|
+
} from './attachment-validation.js';
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
|
|
19
|
+
async function pdfWithPages(pageCount: number, useObjectStreams = true): Promise<Uint8Array> {
|
|
20
|
+
const document = await PDFDocument.create();
|
|
21
|
+
for (let index = 0; index < pageCount; index += 1) document.addPage();
|
|
22
|
+
return document.save({ useObjectStreams });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expectReason(action: () => unknown, reason: AttachmentValidationError['reason']): void {
|
|
26
|
+
try {
|
|
27
|
+
action();
|
|
28
|
+
throw new Error('expected validation to fail');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
expect(error).toBeInstanceOf(AttachmentValidationError);
|
|
31
|
+
if (error instanceof AttachmentValidationError) {
|
|
32
|
+
expect(error.code).toBe('attachment_invalid');
|
|
33
|
+
expect(error.reason).toBe(reason);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function expectAsyncReason(action: () => Promise<unknown>, reason: AttachmentValidationError['reason']): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await action();
|
|
41
|
+
throw new Error('expected validation to fail');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
expect(error).toBeInstanceOf(AttachmentValidationError);
|
|
44
|
+
if (error instanceof AttachmentValidationError) expect(error.reason).toBe(reason);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('attachment size policy', () => {
|
|
49
|
+
test.each([
|
|
50
|
+
['spec.pdf', 'application/pdf', MAX_PDF_ATTACHMENT_BYTES],
|
|
51
|
+
['photo.jpg', 'image/jpeg', MAX_IMAGE_ATTACHMENT_BYTES],
|
|
52
|
+
['notes.md', 'text/markdown', MAX_TEXT_ATTACHMENT_BYTES],
|
|
53
|
+
['bundle.zip', 'application/zip', MAX_ARCHIVE_ATTACHMENT_BYTES],
|
|
54
|
+
['bundle.tgz', 'application/octet-stream', MAX_ARCHIVE_ATTACHMENT_BYTES],
|
|
55
|
+
['orders_prod.dump', 'application/octet-stream', MAX_ARCHIVE_ATTACHMENT_BYTES]
|
|
56
|
+
])('allows the exact limit and rejects one byte more for %s', (fileName, mimeType, limit) => {
|
|
57
|
+
expect(getAttachmentUploadLimit({ fileName, mimeType })).toBe(limit);
|
|
58
|
+
expect(() => assertAttachmentSize({ fileName, mimeType, sizeBytes: limit })).not.toThrow();
|
|
59
|
+
expectReason(() => assertAttachmentSize({ fileName, mimeType, sizeBytes: limit + 1 }), 'too_large');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test.each([
|
|
63
|
+
['spec.pdf', 'application/pdf', 4_500_001, 'This PDF is too large. The limit is 4.5 MB. Split it or attach only the pages you need.'],
|
|
64
|
+
[
|
|
65
|
+
'photo.jpg',
|
|
66
|
+
'IMAGE/JPEG; charset=binary',
|
|
67
|
+
3_750_001,
|
|
68
|
+
'This image is too large. The limit is 3.75 MB after conversion. Resize it or choose a smaller image.'
|
|
69
|
+
],
|
|
70
|
+
['notes.md', '', 5_242_881, 'This text/code file is too large. The limit is 5 MiB. Split it or attach only the content you need.'],
|
|
71
|
+
[
|
|
72
|
+
'bundle.zip',
|
|
73
|
+
'application/zip',
|
|
74
|
+
52_428_801,
|
|
75
|
+
'This archive is too large. The limit is 50 MiB. Remove files from it or split it into smaller archives.'
|
|
76
|
+
]
|
|
77
|
+
])('returns only the applicable size error for %s', (fileName, mimeType, sizeBytes, message) => {
|
|
78
|
+
expect(() => assertAttachmentSize({ fileName, mimeType, sizeBytes })).toThrow(message);
|
|
79
|
+
expect(getAttachmentUploadPolicy({ fileName, mimeType }).maxBytes).toBe(sizeBytes - 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('uses the same policy for MIME types and extension fallback without trusting a conflicting extension', () => {
|
|
83
|
+
expect(getAttachmentUploadPolicy({ fileName: 'SPEC.PDF' })).toEqual(getAttachmentUploadPolicy({ mimeType: 'application/pdf' }));
|
|
84
|
+
expect(getAttachmentUploadPolicy({ fileName: 'spec.pdf', mimeType: 'image/png' }).maxBytes).toBe(3_750_000);
|
|
85
|
+
expect(getAttachmentLimitsHelp(false)).toBe('PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB.');
|
|
86
|
+
expect(getAttachmentLimitsHelp(true)).toBe(
|
|
87
|
+
'PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB. Supported archives and .dump files: 50 MiB.'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('rejects unsupported and Office types', () => {
|
|
92
|
+
expectReason(() => getAttachmentUploadLimit({ fileName: 'logo.svg', mimeType: 'image/svg+xml' }), 'unsupported_type');
|
|
93
|
+
expectReason(() => getAttachmentUploadLimit({ fileName: 'report.docx', mimeType: 'text/plain' }), 'unsupported_type');
|
|
94
|
+
expectReason(() => getAttachmentUploadLimit({ fileName: 'notes.txt', mimeType: 'application/octet-stream' }), 'unsupported_type');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('attachment content validation', () => {
|
|
99
|
+
test.each([
|
|
100
|
+
['image/jpeg', new Uint8Array([0xff, 0xd8, 0xff])],
|
|
101
|
+
['image/png', new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
102
|
+
['image/gif', encoder.encode('GIF89a')],
|
|
103
|
+
['image/webp', encoder.encode('RIFF0000WEBP')]
|
|
104
|
+
])('accepts valid %s magic', async (mimeType, bytes) => {
|
|
105
|
+
await expect(validateAttachmentBytes({ mimeType, bytes })).resolves.toEqual({ pdfPages: 0 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test.each([
|
|
109
|
+
['bundle.zip', 'application/zip', new Uint8Array([0x50, 0x4b, 0x05, 0x06])],
|
|
110
|
+
['bundle.gz', 'application/gzip', new Uint8Array([0x1f, 0x8b])],
|
|
111
|
+
['bundle.tgz', 'application/x-tgz', new Uint8Array([0x1f, 0x8b])]
|
|
112
|
+
])('accepts valid archive magic for %s', async (fileName, mimeType, bytes) => {
|
|
113
|
+
await expect(validateAttachmentBytes({ fileName, mimeType, bytes })).resolves.toEqual({ pdfPages: 0 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('accepts postgres custom-format dump magic for .dump files', async () => {
|
|
117
|
+
const bytes = encoder.encode('PGDMP\x01\x0e\x00');
|
|
118
|
+
await expect(validateAttachmentBytes({ fileName: 'orders_prod.dump', mimeType: 'application/octet-stream', bytes })).resolves.toEqual({
|
|
119
|
+
pdfPages: 0
|
|
120
|
+
});
|
|
121
|
+
await expect(validateAttachmentBytes({ fileName: 'orders_prod.dump', mimeType: '', bytes })).resolves.toEqual({ pdfPages: 0 });
|
|
122
|
+
await expect(validateAttachmentBytes({ fileName: 'orders_prod.dump', mimeType: 'text/plain', bytes })).resolves.toEqual({
|
|
123
|
+
pdfPages: 0
|
|
124
|
+
});
|
|
125
|
+
await expectAsyncReason(
|
|
126
|
+
() =>
|
|
127
|
+
validateAttachmentBytes({
|
|
128
|
+
fileName: 'orders_prod.dump',
|
|
129
|
+
mimeType: 'application/octet-stream',
|
|
130
|
+
bytes: encoder.encode('not a dump')
|
|
131
|
+
}),
|
|
132
|
+
'invalid_content'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('accepts dump mime type without a .dump filename', async () => {
|
|
137
|
+
const bytes = encoder.encode('PGDMP\x01\x0e\x00');
|
|
138
|
+
await expect(validateAttachmentBytes({ fileName: 'orders', mimeType: 'application/x-postgresql-dump', bytes })).resolves.toEqual({
|
|
139
|
+
pdfPages: 0
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test.each([
|
|
144
|
+
['bundle.tgz', new Uint8Array([0x1f, 0x8b])],
|
|
145
|
+
['bundle.gzip', new Uint8Array([0x1f, 0x8b])],
|
|
146
|
+
['bundle.tar.gz', new Uint8Array([0x1f, 0x8b])],
|
|
147
|
+
['bundle.zip', new Uint8Array([0x50, 0x4b, 0x05, 0x06])]
|
|
148
|
+
])('accepts archive filename fallback for generic MIME on %s', async (fileName, bytes) => {
|
|
149
|
+
await expect(validateAttachmentBytes({ fileName, mimeType: 'application/octet-stream', bytes })).resolves.toEqual({ pdfPages: 0 });
|
|
150
|
+
await expectAsyncReason(
|
|
151
|
+
() => validateAttachmentBytes({ fileName, mimeType: 'application/octet-stream', bytes: encoder.encode('not an archive') }),
|
|
152
|
+
'invalid_content'
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('does not use archive filename fallback for an incompatible declared MIME type', async () => {
|
|
157
|
+
await expectAsyncReason(
|
|
158
|
+
() => validateAttachmentBytes({ fileName: 'bundle.tgz', mimeType: 'text/plain', bytes: new Uint8Array([0x1f, 0x8b]) }),
|
|
159
|
+
'invalid_content'
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('accepts UTF-8 text and rejects binary or disguised content', async () => {
|
|
164
|
+
await expect(
|
|
165
|
+
validateAttachmentBytes({ fileName: 'notes.md', mimeType: 'text/markdown', bytes: encoder.encode('hello') })
|
|
166
|
+
).resolves.toEqual({
|
|
167
|
+
pdfPages: 0
|
|
168
|
+
});
|
|
169
|
+
await expectAsyncReason(
|
|
170
|
+
() => validateAttachmentBytes({ fileName: 'notes.txt', mimeType: 'text/plain', bytes: new Uint8Array([0xff, 0x00]) }),
|
|
171
|
+
'invalid_content'
|
|
172
|
+
);
|
|
173
|
+
await expectAsyncReason(
|
|
174
|
+
() => validateAttachmentBytes({ fileName: 'notes.txt', mimeType: 'text/plain', bytes: new Uint8Array([0x50, 0x4b, 0x03, 0x04]) }),
|
|
175
|
+
'invalid_content'
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('rejects renamed random bytes for images and archives', async () => {
|
|
180
|
+
await expectAsyncReason(() => validateAttachmentBytes({ mimeType: 'image/png', bytes: encoder.encode('not png') }), 'invalid_content');
|
|
181
|
+
await expectAsyncReason(
|
|
182
|
+
() => validateAttachmentBytes({ fileName: 'bundle.zip', mimeType: 'application/zip', bytes: encoder.encode('not zip') }),
|
|
183
|
+
'invalid_content'
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('PDF validation', () => {
|
|
189
|
+
test('counts 100 pages in a compressed object stream PDF', async () => {
|
|
190
|
+
const bytes = await pdfWithPages(100);
|
|
191
|
+
await expect(validateAttachmentBytes({ fileName: 'spec.pdf', mimeType: 'application/pdf', bytes })).resolves.toEqual({ pdfPages: 100 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('rejects 101 pages', async () => {
|
|
195
|
+
const bytes = await pdfWithPages(101, false);
|
|
196
|
+
await expectAsyncReason(() => validateAttachmentBytes({ mimeType: 'application/pdf', bytes }), 'too_many_pdf_pages');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('rejects malformed, empty, and encrypted PDFs', async () => {
|
|
200
|
+
await expectAsyncReason(
|
|
201
|
+
() => validateAttachmentBytes({ mimeType: 'application/pdf', bytes: encoder.encode('%PDF-broken') }),
|
|
202
|
+
'invalid_content'
|
|
203
|
+
);
|
|
204
|
+
await expectAsyncReason(() => validateAttachmentBytes({ mimeType: 'application/pdf', bytes: new Uint8Array() }), 'invalid_content');
|
|
205
|
+
|
|
206
|
+
const encrypted = await PDFDocument.create();
|
|
207
|
+
encrypted.addPage();
|
|
208
|
+
encrypted.context.trailerInfo.Encrypt = encrypted.context.register(encrypted.context.obj({ Filter: 'Standard', V: 1 }));
|
|
209
|
+
const encryptedBytes = await encrypted.save();
|
|
210
|
+
await expectAsyncReason(() => validateAttachmentBytes({ mimeType: 'application/pdf', bytes: encryptedBytes }), 'invalid_content');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('rejects a cyclic page tree without recursive traversal', async () => {
|
|
214
|
+
const cyclic = await PDFDocument.create();
|
|
215
|
+
cyclic.addPage();
|
|
216
|
+
const pages = cyclic.catalog.Pages();
|
|
217
|
+
const pagesReference = cyclic.context.getObjectRef(pages);
|
|
218
|
+
if (!pagesReference) throw new Error('expected page tree reference');
|
|
219
|
+
pages.Kids().push(pagesReference);
|
|
220
|
+
|
|
221
|
+
await expectAsyncReason(
|
|
222
|
+
async () => validateAttachmentBytes({ mimeType: 'application/pdf', bytes: await cyclic.save() }),
|
|
223
|
+
'invalid_content'
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { PDFDocument, PDFPageLeaf, PDFPageTree } from 'pdf-lib';
|
|
2
|
+
|
|
3
|
+
import { detectAttachmentArchiveType, isDumpAttachment } from './attachment-delivery.js';
|
|
4
|
+
import { getTextAttachmentType, isOfficeXml } from './attachment-upload.js';
|
|
5
|
+
|
|
6
|
+
const ATTACHMENT_UPLOAD_POLICIES = {
|
|
7
|
+
archive: {
|
|
8
|
+
maxBytes: 50 * 1024 * 1024,
|
|
9
|
+
sizeLabel: '50 MiB',
|
|
10
|
+
label: 'archive',
|
|
11
|
+
advice: 'Remove files from it or split it into smaller archives.',
|
|
12
|
+
invalidContentMessage: 'This archive could not be read. Re-create it as a ZIP, GZIP, or TGZ archive and try again.'
|
|
13
|
+
},
|
|
14
|
+
dump: {
|
|
15
|
+
maxBytes: 50 * 1024 * 1024,
|
|
16
|
+
sizeLabel: '50 MiB',
|
|
17
|
+
label: 'database dump',
|
|
18
|
+
advice: 'Produce a smaller dump or split the data before attaching it.',
|
|
19
|
+
invalidContentMessage:
|
|
20
|
+
'This database dump could not be read. Re-create it with pg_dump custom format (-Fc) so the file starts with PGDMP, then try again.'
|
|
21
|
+
},
|
|
22
|
+
image: {
|
|
23
|
+
maxBytes: 3_750_000,
|
|
24
|
+
sizeLabel: '3.75 MB after conversion',
|
|
25
|
+
label: 'image',
|
|
26
|
+
advice: 'Resize it or choose a smaller image.',
|
|
27
|
+
invalidContentMessage: 'This image could not be read. Re-export it as a JPEG, PNG, GIF, or WebP image and try again.'
|
|
28
|
+
},
|
|
29
|
+
pdf: {
|
|
30
|
+
maxBytes: 4_500_000,
|
|
31
|
+
sizeLabel: '4.5 MB',
|
|
32
|
+
label: 'PDF',
|
|
33
|
+
advice: 'Split it or attach only the pages you need.',
|
|
34
|
+
invalidContentMessage: 'This PDF could not be read. Re-export it without encryption or password protection and try again.'
|
|
35
|
+
},
|
|
36
|
+
text: {
|
|
37
|
+
maxBytes: 5 * 1024 * 1024,
|
|
38
|
+
sizeLabel: '5 MiB',
|
|
39
|
+
label: 'text/code file',
|
|
40
|
+
advice: 'Split it or attach only the content you need.',
|
|
41
|
+
invalidContentMessage: 'This text/code file could not be read. Save it as UTF-8 text and try again.'
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const MAX_PDF_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.pdf.maxBytes;
|
|
46
|
+
export const MAX_IMAGE_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.image.maxBytes;
|
|
47
|
+
export const MAX_TEXT_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.text.maxBytes;
|
|
48
|
+
export const MAX_ARCHIVE_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.archive.maxBytes;
|
|
49
|
+
export const MAX_PDF_ATTACHMENT_PAGES = 100;
|
|
50
|
+
|
|
51
|
+
export function getAttachmentLimitsHelp(archiveAttachmentsEnabled: boolean): string {
|
|
52
|
+
const { archive, image, pdf, text } = ATTACHMENT_UPLOAD_POLICIES;
|
|
53
|
+
return `PDFs: ${pdf.sizeLabel} each, ${MAX_PDF_ATTACHMENT_PAGES} pages total. Images: ${image.sizeLabel}. Text/code: ${text.sizeLabel}.${archiveAttachmentsEnabled ? ` Supported archives and .dump files: ${archive.sizeLabel}.` : ''}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AttachmentValidationReason = 'invalid_content' | 'too_large' | 'too_many_pdf_pages' | 'unsupported_type';
|
|
57
|
+
|
|
58
|
+
const VALIDATION_MESSAGES: Record<AttachmentValidationReason, string> = {
|
|
59
|
+
invalid_content: 'This file could not be read. Re-export it and try again.',
|
|
60
|
+
too_large: 'This upload is too large. Reduce its size and try again.',
|
|
61
|
+
too_many_pdf_pages: `Clark supports up to ${MAX_PDF_ATTACHMENT_PAGES} PDF pages per request. Remove another PDF or attach fewer pages.`,
|
|
62
|
+
unsupported_type:
|
|
63
|
+
'This file type is not supported. Use an image, PDF, text/code file, a supported ZIP, GZIP, or TGZ archive, or a Postgres .dump file.'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export class AttachmentValidationError extends Error {
|
|
67
|
+
readonly code = 'attachment_invalid';
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
readonly reason: AttachmentValidationReason,
|
|
71
|
+
input?: AttachmentTypeInput
|
|
72
|
+
) {
|
|
73
|
+
const policy = input && (reason === 'too_large' || reason === 'invalid_content') ? getAttachmentUploadPolicy(input) : undefined;
|
|
74
|
+
super(policy ? (reason === 'too_large' ? policy.tooLargeMessage : policy.invalidContentMessage) : VALIDATION_MESSAGES[reason]);
|
|
75
|
+
this.name = 'AttachmentValidationError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type AttachmentTypeInput = {
|
|
80
|
+
fileName?: string;
|
|
81
|
+
mimeType?: string | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type AttachmentKind = 'archive' | 'dump' | 'gif' | 'jpeg' | 'pdf' | 'png' | 'text' | 'webp';
|
|
85
|
+
const MAX_PDF_PAGE_TREE_NODES = 1_000;
|
|
86
|
+
const POSTGRES_DUMP_MAGIC = [0x50, 0x47, 0x44, 0x4d, 0x50] as const;
|
|
87
|
+
|
|
88
|
+
function normalizedMimeType(mimeType?: string | null): string {
|
|
89
|
+
return mimeType?.trim().toLowerCase().split(';', 1)[0] ?? '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizedFileName(fileName?: string): string {
|
|
93
|
+
return fileName?.trim().toLowerCase() ?? '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getAttachmentKind(input: AttachmentTypeInput): AttachmentKind {
|
|
97
|
+
if (isOfficeXml(input)) {
|
|
98
|
+
throw new AttachmentValidationError('unsupported_type');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const mimeType = normalizedMimeType(input.mimeType);
|
|
102
|
+
const fileName = normalizedFileName(input.fileName);
|
|
103
|
+
if (mimeType) {
|
|
104
|
+
if (mimeType === 'application/octet-stream' && detectAttachmentArchiveType({ fileName })) return 'archive';
|
|
105
|
+
if (isDumpAttachment(input)) return 'dump';
|
|
106
|
+
if (mimeType === 'application/pdf') return 'pdf';
|
|
107
|
+
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return 'jpeg';
|
|
108
|
+
if (mimeType === 'image/png') return 'png';
|
|
109
|
+
if (mimeType === 'image/gif') return 'gif';
|
|
110
|
+
if (mimeType === 'image/webp') return 'webp';
|
|
111
|
+
if (detectAttachmentArchiveType({ mimeType })) return 'archive';
|
|
112
|
+
if (mimeType.startsWith('image/')) throw new AttachmentValidationError('unsupported_type');
|
|
113
|
+
if (getTextAttachmentType({ mimeType })) return 'text';
|
|
114
|
+
throw new AttachmentValidationError('unsupported_type');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (detectAttachmentArchiveType({ fileName })) return 'archive';
|
|
118
|
+
if (isDumpAttachment(input)) return 'dump';
|
|
119
|
+
if (fileName.endsWith('.pdf')) return 'pdf';
|
|
120
|
+
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg')) return 'jpeg';
|
|
121
|
+
if (fileName.endsWith('.png')) return 'png';
|
|
122
|
+
if (fileName.endsWith('.gif')) return 'gif';
|
|
123
|
+
if (fileName.endsWith('.webp')) return 'webp';
|
|
124
|
+
if (getTextAttachmentType({ fileName })) return 'text';
|
|
125
|
+
throw new AttachmentValidationError('unsupported_type');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve the validated upload policy by MIME, falling back to the extension only when MIME is absent or generic. */
|
|
129
|
+
export function getAttachmentUploadPolicy(input: AttachmentTypeInput) {
|
|
130
|
+
const kind = getAttachmentKind(input);
|
|
131
|
+
const policy = ATTACHMENT_UPLOAD_POLICIES[kind === 'archive' || kind === 'dump' || kind === 'pdf' || kind === 'text' ? kind : 'image'];
|
|
132
|
+
return {
|
|
133
|
+
...policy,
|
|
134
|
+
tooLargeMessage: `This ${policy.label} is too large. The limit is ${policy.sizeLabel}. ${policy.advice}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getAttachmentUploadLimit(input: AttachmentTypeInput): number {
|
|
139
|
+
return getAttachmentUploadPolicy(input).maxBytes;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function assertAttachmentSize(input: AttachmentTypeInput & { sizeBytes: number }): void {
|
|
143
|
+
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0) {
|
|
144
|
+
throw new AttachmentValidationError('invalid_content', input);
|
|
145
|
+
}
|
|
146
|
+
if (input.sizeBytes > getAttachmentUploadLimit(input)) {
|
|
147
|
+
throw new AttachmentValidationError('too_large', input);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function assertAttachmentPdfPages(pages: number): void {
|
|
152
|
+
if (pages > MAX_PDF_ATTACHMENT_PAGES) throw new AttachmentValidationError('too_many_pdf_pages');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function startsWith(bytes: Uint8Array, signature: readonly number[]): boolean {
|
|
156
|
+
return signature.every((value, index) => bytes[index] === value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isZip(bytes: Uint8Array): boolean {
|
|
160
|
+
return (
|
|
161
|
+
startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
|
|
162
|
+
startsWith(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
|
|
163
|
+
startsWith(bytes, [0x50, 0x4b, 0x07, 0x08])
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateImage(kind: AttachmentKind, bytes: Uint8Array): void {
|
|
168
|
+
const valid =
|
|
169
|
+
(kind === 'jpeg' && startsWith(bytes, [0xff, 0xd8, 0xff])) ||
|
|
170
|
+
(kind === 'png' && startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ||
|
|
171
|
+
(kind === 'gif' &&
|
|
172
|
+
(new TextDecoder().decode(bytes.subarray(0, 6)) === 'GIF87a' || new TextDecoder().decode(bytes.subarray(0, 6)) === 'GIF89a')) ||
|
|
173
|
+
(kind === 'webp' &&
|
|
174
|
+
new TextDecoder().decode(bytes.subarray(0, 4)) === 'RIFF' &&
|
|
175
|
+
new TextDecoder().decode(bytes.subarray(8, 12)) === 'WEBP');
|
|
176
|
+
if (!valid) throw new AttachmentValidationError('invalid_content');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateArchive(input: AttachmentTypeInput, bytes: Uint8Array): void {
|
|
180
|
+
const archiveType = detectAttachmentArchiveType(input);
|
|
181
|
+
const valid = archiveType === 'zip' ? isZip(bytes) : startsWith(bytes, [0x1f, 0x8b]);
|
|
182
|
+
if (!valid) throw new AttachmentValidationError('invalid_content');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateDump(bytes: Uint8Array): void {
|
|
186
|
+
if (!startsWith(bytes, POSTGRES_DUMP_MAGIC)) {
|
|
187
|
+
throw new AttachmentValidationError('invalid_content');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function validateText(bytes: Uint8Array): void {
|
|
192
|
+
if (
|
|
193
|
+
bytes.includes(0) ||
|
|
194
|
+
startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]) ||
|
|
195
|
+
startsWith(bytes, [0xff, 0xd8, 0xff]) ||
|
|
196
|
+
startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]) ||
|
|
197
|
+
new TextDecoder().decode(bytes.subarray(0, 3)) === 'GIF' ||
|
|
198
|
+
(new TextDecoder().decode(bytes.subarray(0, 4)) === 'RIFF' && new TextDecoder().decode(bytes.subarray(8, 12)) === 'WEBP') ||
|
|
199
|
+
isZip(bytes) ||
|
|
200
|
+
startsWith(bytes, [0x1f, 0x8b])
|
|
201
|
+
) {
|
|
202
|
+
throw new AttachmentValidationError('invalid_content');
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new AttachmentValidationError('invalid_content');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function countPdfPages(document: PDFDocument): number {
|
|
212
|
+
const pending: (PDFPageLeaf | PDFPageTree)[] = [document.catalog.Pages()];
|
|
213
|
+
const visited = new Set<PDFPageTree>();
|
|
214
|
+
let pages = 0;
|
|
215
|
+
|
|
216
|
+
while (pending.length > 0) {
|
|
217
|
+
const node = pending.pop();
|
|
218
|
+
if (node instanceof PDFPageLeaf) {
|
|
219
|
+
pages += 1;
|
|
220
|
+
assertAttachmentPdfPages(pages);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!(node instanceof PDFPageTree) || visited.has(node)) throw new AttachmentValidationError('invalid_content');
|
|
224
|
+
visited.add(node);
|
|
225
|
+
if (visited.size > MAX_PDF_PAGE_TREE_NODES) throw new AttachmentValidationError('invalid_content');
|
|
226
|
+
const kids = node.Kids();
|
|
227
|
+
for (let index = 0; index < kids.size(); index += 1) {
|
|
228
|
+
const kid = kids.lookup(index);
|
|
229
|
+
if (!(kid instanceof PDFPageLeaf) && !(kid instanceof PDFPageTree)) {
|
|
230
|
+
throw new AttachmentValidationError('invalid_content');
|
|
231
|
+
}
|
|
232
|
+
pending.push(kid);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (pages === 0) throw new AttachmentValidationError('invalid_content');
|
|
237
|
+
return pages;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function validatePdf(bytes: Uint8Array): Promise<number> {
|
|
241
|
+
// Match the existing upload sniff: tolerate a BOM or producer prefix.
|
|
242
|
+
if (!new TextDecoder('latin1').decode(bytes.subarray(0, 1028)).includes('%PDF-')) {
|
|
243
|
+
throw new AttachmentValidationError('invalid_content');
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const document = await PDFDocument.load(bytes, { throwOnInvalidObject: true, updateMetadata: false });
|
|
247
|
+
return countPdfPages(document);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof AttachmentValidationError) throw error;
|
|
250
|
+
throw new AttachmentValidationError('invalid_content');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function validateAttachmentBytes(input: AttachmentTypeInput & { bytes: Uint8Array }): Promise<{ pdfPages: number }> {
|
|
255
|
+
assertAttachmentSize({ ...input, sizeBytes: input.bytes.byteLength });
|
|
256
|
+
const kind = getAttachmentKind(input);
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
if (kind === 'pdf') return { pdfPages: await validatePdf(input.bytes) };
|
|
260
|
+
if (kind === 'text') validateText(input.bytes);
|
|
261
|
+
else if (kind === 'archive') validateArchive(input, input.bytes);
|
|
262
|
+
else if (kind === 'dump') validateDump(input.bytes);
|
|
263
|
+
else validateImage(kind, input.bytes);
|
|
264
|
+
return { pdfPages: 0 };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof AttachmentValidationError) throw new AttachmentValidationError(error.reason, input);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/utils/index.ts
CHANGED