diff --git a/src/document-workbench/doc0-upload-client.ts b/src/document-workbench/doc0-upload-client.ts index 1aa6eb8..05d1673 100644 --- a/src/document-workbench/doc0-upload-client.ts +++ b/src/document-workbench/doc0-upload-client.ts @@ -51,6 +51,45 @@ export type Doc0Inspection = | { status: 'not_found' | 'in_progress' | 'reconciliation_required' } | { status: 'completed_recorded'; documentId: string; versionId: string }; +/** + * DOC0 fingerprints the multipart parser's filename and MIME, not the values + * supplied to FormData. Use an ASCII wire name and a restricted, normalized + * MIME so their parser interpretation is deterministic. The original name is + * preserved separately as DOC0's displayName. + * + * Internal contract only: callers must retain this identity before dispatch, + * authorize its scope, and keep the same input bytes/metadata until dispatch. + */ +export function prepareDoc0UploadIdentity(input: Doc0UploadInput): { + sourceSha256: string; + sourceSizeBytes: number; + requestFingerprint: string; + wireFilename: string; + wireMimeType: string; +} { + if ( + ![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType] + .every((value) => typeof value === 'string' && value.trim()) || + ![input.tenantId, input.actorId, input.requestId, input.idempotencyKey] + .every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) || + !['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) || + !(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0 + ) throw new Error('Invalid DOC0 upload input'); + const wireMimeType = input.mimeType.toLowerCase(); + if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) { + throw new Error('Invalid DOC0 upload MIME type'); + } + const extension = /\.([a-z0-9]{1,16})$/i.exec(input.filename)?.[1]?.toLowerCase() ?? 'bin'; + const wireFilename = `source.${extension}`; + const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex'); + const sourceSizeBytes = input.bytes.byteLength; + const requestFingerprint = createHash('sha256').update(JSON.stringify([ + 'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256, + sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null, + ])).digest('hex'); + return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType }; +} + const MAX_RECEIPT_BYTES = 128 * 1024; async function readBoundedJson(response: Response): Promise { @@ -108,26 +147,9 @@ export class Doc0UploadClient { } async upload(input: Doc0UploadInput): Promise { - if ( - ![ - input.tenantId, - input.projectId, - input.actorId, - input.requestId, - input.idempotencyKey, - input.filename, - input.mimeType, - ].every((value) => typeof value === 'string' && value.trim()) || - !['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes( - input.classification, - ) || - !(input.bytes instanceof Uint8Array) || - input.bytes.byteLength === 0 || - input.bytes.byteLength > this.options.maxBytes - ) { - throw new Error('Invalid DOC0 upload input'); - } - const expectedHash = createHash('sha256').update(input.bytes).digest('hex'); + const identity = prepareDoc0UploadIdentity(input); + if (identity.sourceSizeBytes > this.options.maxBytes) throw new Error('Invalid DOC0 upload input'); + const expectedHash = identity.sourceSha256; const body = new FormData(); body.set( 'input', @@ -138,7 +160,7 @@ export class Doc0UploadClient { classification: input.classification, }), ); - body.set('file', new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }), input.filename); + body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename); let response: Response; try { response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, { diff --git a/tests/document-workbench/doc0-upload-client.test.ts b/tests/document-workbench/doc0-upload-client.test.ts index ed2ed5e..36a04ca 100644 --- a/tests/document-workbench/doc0-upload-client.test.ts +++ b/tests/document-workbench/doc0-upload-client.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; -import { Doc0UploadClient, Doc0UploadOutcomeUnknownError, Doc0InspectionUnavailableError } from '../../src/document-workbench/doc0-upload-client'; +import { Doc0UploadClient, Doc0UploadOutcomeUnknownError, Doc0InspectionUnavailableError, prepareDoc0UploadIdentity } from '../../src/document-workbench/doc0-upload-client'; const bytes = new TextEncoder().encode('source bytes'); const input = { @@ -48,6 +48,39 @@ describe('DOC0 upload client', () => { pipelineStage: 'parsed', }); }); + it.each(['rapport åäö.xlsx', 'folder/name.txt', 'Back\\path.txt', 'A"B.txt', 'row\r\nname.txt'])( + 'uses stable multipart metadata while retaining original name: %s', async (filename) => { + const fixture = { ...input, filename, mimeType: 'APPLICATION/PDF' }; + const identity = prepareDoc0UploadIdentity(fixture); + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const form = init?.body as FormData; + expect(JSON.parse(form.get('input') as string).displayName).toBe(filename); + expect((form.get('file') as File).name).toBe(identity.wireFilename); + expect((form.get('file') as File).type).toBe('application/pdf'); + return Response.json(receipt, { status: 201 }); + }); + await new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }).upload(fixture); + expect(identity.sourceSha256).toBe(receipt.currentVersion.checksumSha256); + expect(identity.requestFingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(fetchImpl).toHaveBeenCalledOnce(); + }, + ); + it('rejects non-canonical identity headers before any upload', async () => { + const fetchImpl = vi.fn(); + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); + for (const tenantId of [' tenant-a', 'tenant-a ', 'tenant-å', 'tenant-\n']) { + await expect(client.upload({ ...input, tenantId })).rejects.toThrow(/Invalid/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('rejects ambiguous MIME values before any upload', async () => { + const fetchImpl = vi.fn(); + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); + for (const mimeType of ['text/plain; charset=utf-8', 'text/å', ' text/plain', 'not-a-mime']) { + await expect(client.upload({ ...input, mimeType })).rejects.toThrow(/MIME/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); it.each([ { document: { ...receipt.document, tenantId: 'tenant-b' } }, { document: { ...receipt.document, projectId: 'other' } },