| author | |
| committer | |
| log | 979589d6d82d2757188f977dc4869813183ebb49 |
| tree | 49a17396af161cdb98491afa0541969f3bff506f |
| parent | b03534fa7b847e8403322e416189c6293e6846e0 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 182 insertions(+), 3 deletions(-)
apps/desktop/src/updates/DesktopUpdates.test.ts+112-3| ... | ... | @@ -430,9 +430,10 @@ describe("DesktopUpdates", () => { |
| 430 | 430 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 431 | 431 | }); |
| 432 | 432 | |
| 433 | it.effect("logs bounded updater failure context without exposing the cause", () => { | |
| 434 | const cause = new Error( | |
| 435 | "request failed for https://user:secret@example.com/update?token=secret", | |
| 433 | it.effect("logs the redacted cause of a rejected update check", () => { | |
| 434 | const cause = Object.assign( | |
| 435 | new Error("request failed for https://user:secret@example.com/update?token=secret"), | |
| 436 | { code: "ERR_UPDATER_LATEST_VERSION_NOT_FOUND" }, | |
| 436 | 437 | ); |
| 437 | 438 | const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ |
| 438 | 439 | channel: null, |
| ... | ... | @@ -459,6 +460,10 @@ describe("DesktopUpdates", () => { |
| 459 | 460 | assert.isDefined(loggedAnnotation); |
| 460 | 461 | assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); |
| 461 | 462 | assert.isNull(loggedAnnotation.channel); |
| 463 | assert.equal( | |
| 464 | loggedAnnotation.cause, | |
| 465 | "Error [ERR_UPDATER_LATEST_VERSION_NOT_FOUND]: request failed for [redacted URL]", | |
| 466 | ); | |
| 462 | 467 | assert.notProperty(loggedAnnotation, "error"); |
| 463 | 468 | assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); |
| 464 | 469 | assert.equal( |
| ... | ... | @@ -478,6 +483,49 @@ describe("DesktopUpdates", () => { |
| 478 | 483 | ); |
| 479 | 484 | }); |
| 480 | 485 | |
| 486 | it.effect("logs the redacted cause of an updater error event", () => { | |
| 487 | const harness = makeHarness(); | |
| 488 | const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), { | |
| 489 | code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", | |
| 490 | }); | |
| 491 | const loggedAnnotations: Array<Record<string, unknown>> = []; | |
| 492 | const logger = Logger.make(({ fiber }) => { | |
| 493 | const annotations = fiber.getRef(References.CurrentLogAnnotations); | |
| 494 | if (annotations.errorTag === "DesktopUpdaterReportedError") { | |
| 495 | loggedAnnotations.push(annotations); | |
| 496 | } | |
| 497 | }); | |
| 498 | ||
| 499 | return Effect.scoped( | |
| 500 | Effect.gen(function* () { | |
| 501 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 502 | yield* updates.configure; | |
| 503 | ||
| 504 | harness.emit("error", cause); | |
| 505 | yield* flushCallbacks; | |
| 506 | ||
| 507 | const loggedAnnotation = loggedAnnotations.at(-1); | |
| 508 | assert.isDefined(loggedAnnotation); | |
| 509 | assert.equal(loggedAnnotation.operation, "background"); | |
| 510 | assert.equal( | |
| 511 | loggedAnnotation.cause, | |
| 512 | "Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found", | |
| 513 | ); | |
| 514 | assert.notInclude(JSON.stringify(loggedAnnotations), "secret"); | |
| 515 | const state = yield* updates.getState; | |
| 516 | assert.equal(state.message, "Desktop updater background operation reported an error."); | |
| 517 | }), | |
| 518 | ).pipe( | |
| 519 | Effect.provide( | |
| 520 | Layer.mergeAll( | |
| 521 | TestClock.layer(), | |
| 522 | harness.layer, | |
| 523 | Logger.layer([logger], { mergeWithExisting: false }), | |
| 524 | ), | |
| 525 | ), | |
| 526 | ); | |
| 527 | }); | |
| 528 | ||
| 481 | 529 | it.effect("recovers download state after an unexpected setup failure", () => { |
| 482 | 530 | let disableDifferentialCalls = 0; |
| 483 | 531 | const harness = makeHarness({ |
| ... | ... | @@ -879,3 +927,64 @@ describe("DesktopUpdates", () => { |
| 879 | 927 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 880 | 928 | }); |
| 881 | 929 | }); |
| 930 | ||
| 931 | describe("describeUpdateErrorCause", () => { | |
| 932 | it("preserves network and nested failure reasons", () => { | |
| 933 | const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), { | |
| 934 | code: "ENOTFOUND", | |
| 935 | }); | |
| 936 | const cause = new Error("fetch failed", { cause: networkError }); | |
| 937 | ||
| 938 | assert.equal( | |
| 939 | DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)), | |
| 940 | "Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com", | |
| 941 | ); | |
| 942 | assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]"); | |
| 943 | }); | |
| 944 | ||
| 945 | it("removes URLs, credentials, response bodies, and headers from summaries", () => { | |
| 946 | const messages = [ | |
| 947 | "Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403", | |
| 948 | "Request failed: Bearer bearer-secret", | |
| 949 | "Request failed: Basic basic-secret", | |
| 950 | "Request failed: Authorization: Bearer header-secret", | |
| 951 | "Request failed: Cookie=session-secret", | |
| 952 | "Request failed: api_key=\"key-secret\" password='password-secret' access_token=token-secret", | |
| 953 | '404 Not Found\n{"body":"body-secret"}\nHeaders: cookie=header-secret', | |
| 954 | "Request failed\rHeaders: cookie=header-secret", | |
| 955 | ]; | |
| 956 | ||
| 957 | for (const message of messages) { | |
| 958 | const summary = DesktopUpdates.describeUpdateErrorCause(new Error(message)); | |
| 959 | assert.include(summary, "Error:"); | |
| 960 | assert.notInclude(summary, "secret"); | |
| 961 | assert.notInclude(summary, "https://"); | |
| 962 | assert.notInclude(summary, "Headers:"); | |
| 963 | } | |
| 964 | assert.equal( | |
| 965 | DesktopUpdates.describeUpdateErrorCause("404 Not Found\nbody-secret"), | |
| 966 | "404 Not Found", | |
| 967 | ); | |
| 968 | }); | |
| 969 | ||
| 970 | it("bounds long messages, parallel failures, and cyclic causes", () => { | |
| 971 | const longError = new Error("x".repeat(10_000)); | |
| 972 | const parallel = Cause.combine(Cause.fail(longError), Cause.die(longError)); | |
| 973 | assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(parallel).length, 1024); | |
| 974 | ||
| 975 | const cyclic = new Error("retry failed"); | |
| 976 | cyclic.cause = cyclic; | |
| 977 | assert.include(DesktopUpdates.describeUpdateErrorCause(cyclic), "[truncated cause]"); | |
| 978 | assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(cyclic).length, 1024); | |
| 979 | }); | |
| 980 | ||
| 981 | it("does not serialize arbitrary payloads or let unreadable errors break logging", () => { | |
| 982 | assert.equal(DesktopUpdates.describeUpdateErrorCause({ payload: "secret" }), "object"); | |
| 983 | const unreadable = Object.defineProperty({}, "message", { | |
| 984 | get() { | |
| 985 | throw new Error("unreadable error"); | |
| 986 | }, | |
| 987 | }); | |
| 988 | assert.equal(DesktopUpdates.describeUpdateErrorCause(unreadable), "UnknownError"); | |
| 989 | }); | |
| 990 | }); |
apps/desktop/src/updates/DesktopUpdates.ts+70| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type DesktopUpdateCheckResult, |
| 8 | 8 | type DesktopUpdateState, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | import { errorTag } from "@t3tools/shared/observability"; | |
| 10 | 11 | import * as Cause from "effect/Cause"; |
| 11 | 12 | import * as Context from "effect/Context"; |
| 12 | 13 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -196,6 +197,63 @@ const { |
| 196 | 197 | logError: logUpdaterError, |
| 197 | 198 | } = DesktopObservability.makeComponentLogger("desktop-updater"); |
| 198 | 199 | |
| 200 | function redactMessage(message: string): string { | |
| 201 | // Updater HTTP errors append response bodies and headers after the first line. | |
| 202 | return (message.split(/[\r\n]/, 1)[0] ?? "") | |
| 203 | .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]") | |
| 204 | .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]") | |
| 205 | .replace( | |
| 206 | /\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*$/gi, | |
| 207 | "[redacted header]", | |
| 208 | ) | |
| 209 | .replace( | |
| 210 | /\b[\w-]*(?:token|password|secret|credential|api[_-]?key)[\w-]*\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, | |
| 211 | "[redacted credential]", | |
| 212 | ) | |
| 213 | .slice(0, 512); | |
| 214 | } | |
| 215 | ||
| 216 | function summarizeCause(cause: unknown, depth: number): string { | |
| 217 | if (depth >= 3) return "[truncated cause]"; | |
| 218 | if (Cause.isCause(cause)) { | |
| 219 | return cause.reasons | |
| 220 | .slice(0, 3) | |
| 221 | .map((reason) => | |
| 222 | reason._tag === "Fail" | |
| 223 | ? summarizeCause(reason.error, depth + 1) | |
| 224 | : reason._tag === "Die" | |
| 225 | ? summarizeCause(reason.defect, depth + 1) | |
| 226 | : "Interrupted", | |
| 227 | ) | |
| 228 | .join("; "); | |
| 229 | } | |
| 230 | if (typeof cause === "string") return redactMessage(cause); | |
| 231 | if (typeof cause !== "object" || cause === null) return errorTag(cause); | |
| 232 | ||
| 233 | const error = cause as { | |
| 234 | readonly message?: unknown; | |
| 235 | readonly code?: unknown; | |
| 236 | readonly cause?: unknown; | |
| 237 | }; | |
| 238 | const code = | |
| 239 | typeof error.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code) | |
| 240 | ? ` [${error.code}]` | |
| 241 | : ""; | |
| 242 | const message = typeof error.message === "string" ? `: ${redactMessage(error.message)}` : ""; | |
| 243 | const nested = | |
| 244 | error.cause === undefined ? "" : `; caused by ${summarizeCause(error.cause, depth + 1)}`; | |
| 245 | return `${errorTag(cause)}${code}${message}${nested}`; | |
| 246 | } | |
| 247 | ||
| 248 | /** Log a bounded cause summary without stacks, response payloads, or request credentials. */ | |
| 249 | export function describeUpdateErrorCause(cause: unknown): string { | |
| 250 | try { | |
| 251 | return summarizeCause(cause, 0).slice(0, 1024); | |
| 252 | } catch { | |
| 253 | return "UnknownError"; | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 199 | 257 | function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> { |
| 200 | 258 | const entries: Record<string, string> = {}; |
| 201 | 259 | for (const line of raw.split("\n")) { |
| ... | ... | @@ -442,6 +500,7 @@ export const make = Effect.gen(function* () { |
| 442 | 500 | ); |
| 443 | 501 | yield* logUpdaterError(error.message, { |
| 444 | 502 | errorTag: error._tag, |
| 503 | cause: describeUpdateErrorCause(error.cause), | |
| 445 | 504 | channel: error.channel, |
| 446 | 505 | }); |
| 447 | 506 | return true; |
| ... | ... | @@ -485,6 +544,7 @@ export const make = Effect.gen(function* () { |
| 485 | 544 | ); |
| 486 | 545 | yield* logUpdaterError(error.message, { |
| 487 | 546 | errorTag: error._tag, |
| 547 | cause: describeUpdateErrorCause(error.cause), | |
| 488 | 548 | channel: error.channel, |
| 489 | 549 | }); |
| 490 | 550 | return { accepted: true, completed: false }; |
| ... | ... | @@ -507,6 +567,7 @@ export const make = Effect.gen(function* () { |
| 507 | 567 | ); |
| 508 | 568 | yield* logUpdaterError(error.message, { |
| 509 | 569 | errorTag: error._tag, |
| 570 | cause: describeUpdateErrorCause(error.cause), | |
| 510 | 571 | action: error.action, |
| 511 | 572 | }); |
| 512 | 573 | return { accepted: true, completed: false }; |
| ... | ... | @@ -655,6 +716,7 @@ export const make = Effect.gen(function* () { |
| 655 | 716 | yield* recoverFailedInstall(error.message); |
| 656 | 717 | yield* logUpdaterError(error.message, { |
| 657 | 718 | errorTag: error._tag, |
| 719 | cause: describeUpdateErrorCause(error.cause), | |
| 658 | 720 | channel: error.channel, |
| 659 | 721 | isSilent: error.isSilent, |
| 660 | 722 | isForceRunAfter: error.isForceRunAfter, |
| ... | ... | @@ -673,6 +735,7 @@ export const make = Effect.gen(function* () { |
| 673 | 735 | yield* recoverFailedInstall(error.message); |
| 674 | 736 | yield* logUpdaterError(error.message, { |
| 675 | 737 | errorTag: error._tag, |
| 738 | cause: describeUpdateErrorCause(error.cause), | |
| 676 | 739 | action: error.action, |
| 677 | 740 | }); |
| 678 | 741 | return { accepted: true, completed: false, failed: true }; |
| ... | ... | @@ -712,6 +775,7 @@ export const make = Effect.gen(function* () { |
| 712 | 775 | const error = new DesktopUpdatePollerError({ poller: "startup", cause }); |
| 713 | 776 | return logUpdaterError(error.message, { |
| 714 | 777 | errorTag: error._tag, |
| 778 | cause: describeUpdateErrorCause(error.cause), | |
| 715 | 779 | poller: error.poller, |
| 716 | 780 | }); |
| 717 | 781 | }), |
| ... | ... | @@ -727,6 +791,7 @@ export const make = Effect.gen(function* () { |
| 727 | 791 | const error = new DesktopUpdatePollerError({ poller: "poll", cause }); |
| 728 | 792 | return logUpdaterError(error.message, { |
| 729 | 793 | errorTag: error._tag, |
| 794 | cause: describeUpdateErrorCause(error.cause), | |
| 730 | 795 | poller: error.poller, |
| 731 | 796 | }); |
| 732 | 797 | }), |
| ... | ... | @@ -782,6 +847,7 @@ export const make = Effect.gen(function* () { |
| 782 | 847 | const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); |
| 783 | 848 | return logUpdaterWarning(error.message, { |
| 784 | 849 | errorTag: error._tag, |
| 850 | cause: describeUpdateErrorCause(error.cause), | |
| 785 | 851 | event: error.event, |
| 786 | 852 | }); |
| 787 | 853 | }), |
| ... | ... | @@ -811,6 +877,7 @@ export const make = Effect.gen(function* () { |
| 811 | 877 | yield* recoverFailedInstall(error.message); |
| 812 | 878 | yield* logUpdaterError(error.message, { |
| 813 | 879 | errorTag: error._tag, |
| 880 | cause: describeUpdateErrorCause(error.cause), | |
| 814 | 881 | operation: error.operation, |
| 815 | 882 | }); |
| 816 | 883 | return; |
| ... | ... | @@ -831,6 +898,7 @@ export const make = Effect.gen(function* () { |
| 831 | 898 | |
| 832 | 899 | yield* logUpdaterError(error.message, { |
| 833 | 900 | errorTag: error._tag, |
| 901 | cause: describeUpdateErrorCause(error.cause), | |
| 834 | 902 | operation: error.operation, |
| 835 | 903 | }); |
| 836 | 904 | }); |
| ... | ... | @@ -861,6 +929,7 @@ export const make = Effect.gen(function* () { |
| 861 | 929 | const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); |
| 862 | 930 | return logUpdaterWarning(error.message, { |
| 863 | 931 | errorTag: error._tag, |
| 932 | cause: describeUpdateErrorCause(error.cause), | |
| 864 | 933 | event: error.event, |
| 865 | 934 | }); |
| 866 | 935 | }), |
| ... | ... | @@ -885,6 +954,7 @@ export const make = Effect.gen(function* () { |
| 885 | 954 | const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); |
| 886 | 955 | return logUpdaterWarning(error.message, { |
| 887 | 956 | errorTag: error._tag, |
| 957 | cause: describeUpdateErrorCause(error.cause), | |
| 888 | 958 | event: error.event, |
| 889 | 959 | }); |
| 890 | 960 | }), |