fix: stabilize DOC0 upload fingerprint metadata
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 28s
masterplan-lock / check (pull_request) Successful in 32s
CI (SIAX Cloud) / quality (push) Successful in 51s
CI (SIAX Cloud) / quality (pull_request) Successful in 50s

This commit is contained in:
2026-11-02 06:52:50 +01:00
parent 4f5a2ea865
commit 47e0264571
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`, {