Stabilize DOC0 multipart upload fingerprints #418
@@ -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<unknown> {
|
||||
@@ -108,26 +147,9 @@ export class Doc0UploadClient {
|
||||
}
|
||||
|
||||
async upload(input: Doc0UploadInput): Promise<Doc0UploadReceipt> {
|
||||
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`, {
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
Reference in New Issue
Block a user