diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 9745662c1209a6496a2dc7ab39dd92fadc6c7538..debb3f080080b41c88e9773e9af768a8ceea892b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -430,9 +430,10 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("logs bounded updater failure context without exposing the cause", () => { - const cause = new Error( - "request failed for https://user:secret@example.com/update?token=secret", + it.effect("logs the redacted cause of a rejected update check", () => { + const cause = Object.assign( + new Error("request failed for https://user:secret@example.com/update?token=secret"), + { code: "ERR_UPDATER_LATEST_VERSION_NOT_FOUND" }, ); const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ channel: null, @@ -459,6 +460,10 @@ describe("DesktopUpdates", () => { assert.isDefined(loggedAnnotation); assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); assert.isNull(loggedAnnotation.channel); + assert.equal( + loggedAnnotation.cause, + "Error [ERR_UPDATER_LATEST_VERSION_NOT_FOUND]: request failed for [redacted URL]", + ); assert.notProperty(loggedAnnotation, "error"); assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); assert.equal( @@ -478,6 +483,49 @@ describe("DesktopUpdates", () => { ); }); + it.effect("logs the redacted cause of an updater error event", () => { + const harness = makeHarness(); + const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), { + code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", + }); + const loggedAnnotations: Array> = []; + const logger = Logger.make(({ fiber }) => { + const annotations = fiber.getRef(References.CurrentLogAnnotations); + if (annotations.errorTag === "DesktopUpdaterReportedError") { + loggedAnnotations.push(annotations); + } + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("error", cause); + yield* flushCallbacks; + + const loggedAnnotation = loggedAnnotations.at(-1); + assert.isDefined(loggedAnnotation); + assert.equal(loggedAnnotation.operation, "background"); + assert.equal( + loggedAnnotation.cause, + "Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found", + ); + assert.notInclude(JSON.stringify(loggedAnnotations), "secret"); + const state = yield* updates.getState; + assert.equal(state.message, "Desktop updater background operation reported an error."); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + harness.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + it.effect("recovers download state after an unexpected setup failure", () => { let disableDifferentialCalls = 0; const harness = makeHarness({ @@ -879,3 +927,64 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); }); + +describe("describeUpdateErrorCause", () => { + it("preserves network and nested failure reasons", () => { + const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), { + code: "ENOTFOUND", + }); + const cause = new Error("fetch failed", { cause: networkError }); + + assert.equal( + DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)), + "Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com", + ); + assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]"); + }); + + it("removes URLs, credentials, response bodies, and headers from summaries", () => { + const messages = [ + "Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403", + "Request failed: Bearer bearer-secret", + "Request failed: Basic basic-secret", + "Request failed: Authorization: Bearer header-secret", + "Request failed: Cookie=session-secret", + "Request failed: api_key=\"key-secret\" password='password-secret' access_token=token-secret", + '404 Not Found\n{"body":"body-secret"}\nHeaders: cookie=header-secret', + "Request failed\rHeaders: cookie=header-secret", + ]; + + for (const message of messages) { + const summary = DesktopUpdates.describeUpdateErrorCause(new Error(message)); + assert.include(summary, "Error:"); + assert.notInclude(summary, "secret"); + assert.notInclude(summary, "https://"); + assert.notInclude(summary, "Headers:"); + } + assert.equal( + DesktopUpdates.describeUpdateErrorCause("404 Not Found\nbody-secret"), + "404 Not Found", + ); + }); + + it("bounds long messages, parallel failures, and cyclic causes", () => { + const longError = new Error("x".repeat(10_000)); + const parallel = Cause.combine(Cause.fail(longError), Cause.die(longError)); + assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(parallel).length, 1024); + + const cyclic = new Error("retry failed"); + cyclic.cause = cyclic; + assert.include(DesktopUpdates.describeUpdateErrorCause(cyclic), "[truncated cause]"); + assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(cyclic).length, 1024); + }); + + it("does not serialize arbitrary payloads or let unreadable errors break logging", () => { + assert.equal(DesktopUpdates.describeUpdateErrorCause({ payload: "secret" }), "object"); + const unreadable = Object.defineProperty({}, "message", { + get() { + throw new Error("unreadable error"); + }, + }); + assert.equal(DesktopUpdates.describeUpdateErrorCause(unreadable), "UnknownError"); + }); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 245925a6e7319f755e759fa55b8a0192abbd1324..f0f483b97bff903c7039c15591a171cab9e81206 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -7,6 +7,7 @@ import { type DesktopUpdateCheckResult, type DesktopUpdateState, } from "@t3tools/contracts"; +import { errorTag } from "@t3tools/shared/observability"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -196,6 +197,63 @@ const { logError: logUpdaterError, } = DesktopObservability.makeComponentLogger("desktop-updater"); +function redactMessage(message: string): string { + // Updater HTTP errors append response bodies and headers after the first line. + return (message.split(/[\r\n]/, 1)[0] ?? "") + .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]") + .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]") + .replace( + /\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*$/gi, + "[redacted header]", + ) + .replace( + /\b[\w-]*(?:token|password|secret|credential|api[_-]?key)[\w-]*\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, + "[redacted credential]", + ) + .slice(0, 512); +} + +function summarizeCause(cause: unknown, depth: number): string { + if (depth >= 3) return "[truncated cause]"; + if (Cause.isCause(cause)) { + return cause.reasons + .slice(0, 3) + .map((reason) => + reason._tag === "Fail" + ? summarizeCause(reason.error, depth + 1) + : reason._tag === "Die" + ? summarizeCause(reason.defect, depth + 1) + : "Interrupted", + ) + .join("; "); + } + if (typeof cause === "string") return redactMessage(cause); + if (typeof cause !== "object" || cause === null) return errorTag(cause); + + const error = cause as { + readonly message?: unknown; + readonly code?: unknown; + readonly cause?: unknown; + }; + const code = + typeof error.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code) + ? ` [${error.code}]` + : ""; + const message = typeof error.message === "string" ? `: ${redactMessage(error.message)}` : ""; + const nested = + error.cause === undefined ? "" : `; caused by ${summarizeCause(error.cause, depth + 1)}`; + return `${errorTag(cause)}${code}${message}${nested}`; +} + +/** Log a bounded cause summary without stacks, response payloads, or request credentials. */ +export function describeUpdateErrorCause(cause: unknown): string { + try { + return summarizeCause(cause, 0).slice(0, 1024); + } catch { + return "UnknownError"; + } +} + function parseAppUpdateYml(raw: string): Effect.Effect> { const entries: Record = {}; for (const line of raw.split("\n")) { @@ -442,6 +500,7 @@ export const make = Effect.gen(function* () { ); yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), channel: error.channel, }); return true; @@ -485,6 +544,7 @@ export const make = Effect.gen(function* () { ); yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), channel: error.channel, }); return { accepted: true, completed: false }; @@ -507,6 +567,7 @@ export const make = Effect.gen(function* () { ); yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), action: error.action, }); return { accepted: true, completed: false }; @@ -655,6 +716,7 @@ export const make = Effect.gen(function* () { yield* recoverFailedInstall(error.message); yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), channel: error.channel, isSilent: error.isSilent, isForceRunAfter: error.isForceRunAfter, @@ -673,6 +735,7 @@ export const make = Effect.gen(function* () { yield* recoverFailedInstall(error.message); yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), action: error.action, }); return { accepted: true, completed: false, failed: true }; @@ -712,6 +775,7 @@ export const make = Effect.gen(function* () { const error = new DesktopUpdatePollerError({ poller: "startup", cause }); return logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), poller: error.poller, }); }), @@ -727,6 +791,7 @@ export const make = Effect.gen(function* () { const error = new DesktopUpdatePollerError({ poller: "poll", cause }); return logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), poller: error.poller, }); }), @@ -782,6 +847,7 @@ export const make = Effect.gen(function* () { const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); return logUpdaterWarning(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), event: error.event, }); }), @@ -811,6 +877,7 @@ export const make = Effect.gen(function* () { yield* recoverFailedInstall(error.message); yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), operation: error.operation, }); return; @@ -831,6 +898,7 @@ export const make = Effect.gen(function* () { yield* logUpdaterError(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), operation: error.operation, }); }); @@ -861,6 +929,7 @@ export const make = Effect.gen(function* () { const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); return logUpdaterWarning(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), event: error.event, }); }), @@ -885,6 +954,7 @@ export const make = Effect.gen(function* () { const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); return logUpdaterWarning(error.message, { errorTag: error._tag, + cause: describeUpdateErrorCause(error.cause), event: error.event, }); }),