Stabilize DOC0 multipart upload fingerprints #418

Merged
admin merged 1 commits from codex/doc0-stable-upload-contract into main 2026-11-02 05:54:42 +00:00
2 changed files with 77 additions and 22 deletions
+43 -21
View File
@@ -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' } },