Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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<Record<string, unknown>> = [];
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({
Expand Down Expand Up @@ -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");
});
});
70 changes: 70 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Option.Option<AppUpdateYmlConfig>> {
const entries: Record<string, string> = {};
for (const line of raw.split("\n")) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
});
}),
Expand All @@ -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,
});
}),
Expand Down Expand Up @@ -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,
});
}),
Expand Down Expand Up @@ -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;
Expand All @@ -831,6 +898,7 @@ export const make = Effect.gen(function* () {

yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
operation: error.operation,
});
});
Expand Down Expand Up @@ -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,
});
}),
Expand All @@ -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,
});
}),
Expand Down
Loading