Skip to content

Commit 4dc329c

Browse files
authored
fix: add OpenPGP payload diagnostics (#130)
Adds safe sync diagnostics for OpenPGP parse/decrypt failures so browser-only repros can identify whether storage returned OpenPGP, HTML, JSON, empty, binary, or unknown bytes and how many bytes were downloaded.\n\nValidation:\n- npx vitest run packages/core/src/sync/workers/download.test.ts\n- npm run build\n- npm run format:check\n- npm run lint:eslint\n- npm test
1 parent 0f3f67c commit 4dc329c

2 files changed

Lines changed: 96 additions & 3 deletions

File tree

packages/core/src/sync/workers/download.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,47 @@ describe("download worker", () => {
156156
expect(deps.storageAdapter.download).not.toHaveBeenCalled();
157157
});
158158

159+
it("records downloaded payload metadata when OpenPGP parsing fails", async () => {
160+
const deps = makeMockDeps();
161+
const htmlPayload = new TextEncoder().encode("<html>not pgp</html>");
162+
(
163+
deps.storageAdapter.download as ReturnType<typeof vi.fn>
164+
).mockResolvedValue(htmlPayload);
165+
(decryptWithPassword as ReturnType<typeof vi.fn>).mockRejectedValue(
166+
new Error("Armored OpenPGP message could not be parsed"),
167+
);
168+
deps.diagnostics = {
169+
onDownloadStart: vi.fn(),
170+
onDownloadEnd: vi.fn(),
171+
onDownloadError: vi.fn(),
172+
onDecryptStart: vi.fn(),
173+
onDecryptEnd: vi.fn(),
174+
onDecryptError: vi.fn(),
175+
onIndexStart: vi.fn(),
176+
onIndexEnd: vi.fn(),
177+
onIndexError: vi.fn(),
178+
onManifestBuildStart: vi.fn(),
179+
onManifestBuildEnd: vi.fn(),
180+
onManifestBuildError: vi.fn(),
181+
onRepair: vi.fn(),
182+
};
183+
184+
await expect(downloadOne(deps, makeFileRecord())).rejects.toThrow(
185+
"Armored OpenPGP message could not be parsed",
186+
);
187+
188+
expect(deps.diagnostics.onDecryptError).toHaveBeenCalledWith(
189+
FILE_ID,
190+
SCOPE,
191+
expect.stringContaining("payloadKind=html"),
192+
);
193+
expect(deps.diagnostics.onDecryptError).toHaveBeenCalledWith(
194+
FILE_ID,
195+
SCOPE,
196+
expect.stringContaining(`encryptedSizeBytes=${htmlPayload.byteLength}`),
197+
);
198+
});
199+
159200
it("backfills missing block sidecars when fileId is already indexed", async () => {
160201
const deps = makeMockDeps();
161202
const existingEntry: IndexEntry = {
@@ -665,7 +706,10 @@ describe("download worker", () => {
665706
expect.objectContaining({
666707
fileId: "file-002",
667708
schemaId: SCHEMA_ID,
709+
scope: SCOPE,
668710
stage: "decrypt",
711+
payloadKind: "unknown",
712+
encryptedSizeBytes: 2,
669713
errorClass: "Error",
670714
message: "Encrypted payload could not be decrypted",
671715
}),

packages/core/src/sync/workers/download.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type { SyncCursor } from "../cursor.js";
1010
import type { Logger } from "../../logger/index.js";
1111
import type { DataStoragePort } from "../../ports/index.js";
1212
import { buildDataBlocks } from "../../storage/blocks/build.js";
13-
import { classifySyncFailure } from "../issues.js";
13+
import {
14+
classifySyncFailure,
15+
inferPayloadKind,
16+
type SyncFailureStage,
17+
type SyncPayloadKind,
18+
} from "../issues.js";
1419

1520
/** Minimal diagnostics hook — keeps core free of lite-specific imports. */
1621
export interface DownloadDiagnosticsHook {
@@ -48,6 +53,13 @@ export interface DownloadResult {
4853
path: string;
4954
}
5055

56+
interface SyncFailureMetadata {
57+
stage?: SyncFailureStage;
58+
scope?: string;
59+
payloadKind?: SyncPayloadKind;
60+
encryptedSizeBytes?: number;
61+
}
62+
5163
/**
5264
* Download and process a single file record from the storage backend:
5365
* 1. Check dedup: skip if fileId already in local index
@@ -121,9 +133,19 @@ export async function downloadOne(
121133
try {
122134
plaintext = await decryptWithPassword(encrypted, scopeKeyHex);
123135
} catch (err) {
124-
const detail = (err as Error).message;
136+
const payloadKind = inferPayloadKind(encrypted);
137+
const encryptedSizeBytes = encrypted.byteLength;
138+
const detail = [
139+
(err as Error).message,
140+
`payloadKind=${payloadKind}`,
141+
`encryptedSizeBytes=${encryptedSizeBytes}`,
142+
].join("; ");
125143
diagnostics?.onDecryptError(record.fileId, schema.scope, detail);
126-
throw err;
144+
throw withSyncFailureMetadata(err, {
145+
scope: schema.scope,
146+
payloadKind,
147+
encryptedSizeBytes,
148+
});
127149
}
128150

129151
// 6. Parse as DataFileEnvelope (validate)
@@ -267,18 +289,26 @@ export async function downloadAll(
267289
results.push(result);
268290
}
269291
} catch (err) {
292+
const metadata = getSyncFailureMetadata(err);
270293
const classified = classifySyncFailure({
271294
error: err,
272295
fileId: file.fileId,
273296
schemaId: file.schemaId,
274297
syncRunId,
298+
stage: metadata.stage,
299+
scope: metadata.scope,
300+
payloadKind: metadata.payloadKind,
301+
encryptedSizeBytes: metadata.encryptedSizeBytes,
275302
});
276303
if (!classified.issue.retryable) {
277304
logger.warn(
278305
{
279306
fileId: file.fileId,
280307
schemaId: file.schemaId,
308+
scope: classified.issue.scope,
281309
stage: classified.issue.stage,
310+
payloadKind: classified.issue.payloadKind,
311+
encryptedSizeBytes: classified.issue.encryptedSizeBytes,
282312
errorClass: classified.issue.errorClass,
283313
message: classified.issue.message,
284314
},
@@ -372,6 +402,25 @@ function uint8ToHex(bytes: Uint8Array): string {
372402
);
373403
}
374404

405+
function withSyncFailureMetadata(
406+
error: unknown,
407+
metadata: SyncFailureMetadata,
408+
): unknown {
409+
if (error instanceof Error) {
410+
Object.assign(error, { syncFailureMetadata: metadata });
411+
return error;
412+
}
413+
return { error, syncFailureMetadata: metadata };
414+
}
415+
416+
function getSyncFailureMetadata(error: unknown): SyncFailureMetadata {
417+
if (typeof error !== "object" || error === null) return {};
418+
const metadata = (error as { syncFailureMetadata?: unknown })
419+
.syncFailureMetadata;
420+
if (typeof metadata !== "object" || metadata === null) return {};
421+
return metadata as SyncFailureMetadata;
422+
}
423+
375424
async function writeBlockSidecars(
376425
storage: DataStoragePort,
377426
envelope: {

0 commit comments

Comments
 (0)