fix(config): do not suppress recovery retry after failed backup restore (#85787)

maybeRecoverSuspiciousConfigRead unconditionally recorded
lastObservedSuspiciousSignature in health state even when
restoredFromBackup was false (copyFile failed). The guard at
resolveConfigReadRecoveryContext then prevented the same
signature from ever being retried, permanently accepting the
suspicious config on every subsequent launch.

Only record the dedup signature when the backup restore
actually succeeded.
This commit is contained in:
Sebastien Tardif
2026-05-24 16:57:21 -07:00
committed by GitHub
parent c422e7240f
commit 5d174a5bec
2 changed files with 97 additions and 12 deletions

View File

@@ -454,6 +454,87 @@ describe("config observe recovery", () => {
});
});
it("retries recovery on next launch after a failed copyFile restore", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, auditPath, warn } = makeDeps(home);
await seedConfigBackup(configPath, recoverableTelegramConfig);
const clobbered = await writeClobberedUpdateChannel(configPath);
const copyError = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
const failingFs: ObserveRecoveryDeps["fs"] = {
...deps.fs,
promises: {
...deps.fs.promises,
copyFile: () => Promise.reject(copyError),
},
};
await maybeRecoverSuspiciousConfigRead({
deps: { ...deps, fs: failingFs },
configPath,
raw: clobbered.raw,
parsed: clobbered.parsed,
});
expectWarnContaining(warn, "Config auto-restore from backup failed:");
const firstEvents = await readObserveEvents(auditPath);
expect(firstEvents).toHaveLength(1);
expect(firstEvents[0]?.restoredFromBackup).toBe(false);
const retryResult = await maybeRecoverSuspiciousConfigRead({
deps,
configPath,
raw: clobbered.raw,
parsed: clobbered.parsed,
});
expect((retryResult.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
await expect(fsp.readFile(configPath, "utf-8")).resolves.not.toBe(clobbered.raw);
const retryEvents = await readObserveEvents(auditPath);
expect(retryEvents).toHaveLength(2);
expect(retryEvents[1]?.restoredFromBackup).toBe(true);
});
});
it("sync recovery retries on next launch after a failed copyFileSync restore", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, auditPath, warn } = makeDeps(home);
await seedConfigBackup(configPath, recoverableTelegramConfig);
const clobbered = await writeClobberedUpdateChannel(configPath);
const copyError = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
const failingFs: ObserveRecoveryDeps["fs"] = {
...deps.fs,
copyFileSync: () => {
throw copyError;
},
};
maybeRecoverSuspiciousConfigReadSync({
deps: { ...deps, fs: failingFs },
configPath,
raw: clobbered.raw,
parsed: clobbered.parsed,
});
expectWarnContaining(warn, "Config auto-restore from backup failed:");
const firstEvents = await readObserveEvents(auditPath);
expect(firstEvents).toHaveLength(1);
expect(firstEvents[0]?.restoredFromBackup).toBe(false);
const retryResult = maybeRecoverSuspiciousConfigReadSync({
deps,
configPath,
raw: clobbered.raw,
parsed: clobbered.parsed,
});
expect((retryResult.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
await expect(fsp.readFile(configPath, "utf-8")).resolves.not.toBe(clobbered.raw);
const retryEvents = await readObserveEvents(auditPath);
expect(retryEvents).toHaveLength(2);
expect(retryEvents[1]?.restoredFromBackup).toBe(true);
});
});
it("dedupes repeated suspicious hashes", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, auditPath } = makeDeps(home);

View File

@@ -680,12 +680,14 @@ export async function maybeRecoverSuspiciousConfigRead(params: {
}),
);
healthState = setConfigHealthEntry(
healthState,
params.configPath,
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
);
await writeConfigHealthState(params.deps, healthState);
if (restoredFromBackup) {
healthState = setConfigHealthEntry(
healthState,
params.configPath,
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
);
await writeConfigHealthState(params.deps, healthState);
}
return { raw: backupRaw, parsed: backupParsed };
}
@@ -790,12 +792,14 @@ export function maybeRecoverSuspiciousConfigReadSync(params: {
}),
);
healthState = setConfigHealthEntry(
healthState,
params.configPath,
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
);
writeConfigHealthStateSync(params.deps, healthState);
if (restoredFromBackup) {
healthState = setConfigHealthEntry(
healthState,
params.configPath,
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
);
writeConfigHealthStateSync(params.deps, healthState);
}
return { raw: backupRaw, parsed: backupParsed };
}