Skip to content

Commit 3656e84

Browse files
SebTardifsteipete
authored andcommitted
fix(config): do not suppress recovery retry after failed backup restore
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.
1 parent c38a9a8 commit 3656e84

2 files changed

Lines changed: 97 additions & 12 deletions

File tree

src/config/io.observe-recovery.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,87 @@ describe("config observe recovery", () => {
454454
});
455455
});
456456

457+
it("retries recovery on next launch after a failed copyFile restore", async () => {
458+
await withSuiteHome(async (home) => {
459+
const { deps, configPath, auditPath, warn } = makeDeps(home);
460+
await seedConfigBackup(configPath, recoverableTelegramConfig);
461+
const clobbered = await writeClobberedUpdateChannel(configPath);
462+
463+
const copyError = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
464+
const failingFs: ObserveRecoveryDeps["fs"] = {
465+
...deps.fs,
466+
promises: {
467+
...deps.fs.promises,
468+
copyFile: () => Promise.reject(copyError),
469+
},
470+
};
471+
await maybeRecoverSuspiciousConfigRead({
472+
deps: { ...deps, fs: failingFs },
473+
configPath,
474+
raw: clobbered.raw,
475+
parsed: clobbered.parsed,
476+
});
477+
478+
expectWarnContaining(warn, "Config auto-restore from backup failed:");
479+
const firstEvents = await readObserveEvents(auditPath);
480+
expect(firstEvents).toHaveLength(1);
481+
expect(firstEvents[0]?.restoredFromBackup).toBe(false);
482+
483+
const retryResult = await maybeRecoverSuspiciousConfigRead({
484+
deps,
485+
configPath,
486+
raw: clobbered.raw,
487+
parsed: clobbered.parsed,
488+
});
489+
490+
expect((retryResult.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
491+
await expect(fsp.readFile(configPath, "utf-8")).resolves.not.toBe(clobbered.raw);
492+
const retryEvents = await readObserveEvents(auditPath);
493+
expect(retryEvents).toHaveLength(2);
494+
expect(retryEvents[1]?.restoredFromBackup).toBe(true);
495+
});
496+
});
497+
498+
it("sync recovery retries on next launch after a failed copyFileSync restore", async () => {
499+
await withSuiteHome(async (home) => {
500+
const { deps, configPath, auditPath, warn } = makeDeps(home);
501+
await seedConfigBackup(configPath, recoverableTelegramConfig);
502+
const clobbered = await writeClobberedUpdateChannel(configPath);
503+
504+
const copyError = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
505+
const failingFs: ObserveRecoveryDeps["fs"] = {
506+
...deps.fs,
507+
copyFileSync: () => {
508+
throw copyError;
509+
},
510+
};
511+
maybeRecoverSuspiciousConfigReadSync({
512+
deps: { ...deps, fs: failingFs },
513+
configPath,
514+
raw: clobbered.raw,
515+
parsed: clobbered.parsed,
516+
});
517+
518+
expectWarnContaining(warn, "Config auto-restore from backup failed:");
519+
const firstEvents = await readObserveEvents(auditPath);
520+
expect(firstEvents).toHaveLength(1);
521+
expect(firstEvents[0]?.restoredFromBackup).toBe(false);
522+
523+
const retryResult = maybeRecoverSuspiciousConfigReadSync({
524+
deps,
525+
configPath,
526+
raw: clobbered.raw,
527+
parsed: clobbered.parsed,
528+
});
529+
530+
expect((retryResult.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
531+
await expect(fsp.readFile(configPath, "utf-8")).resolves.not.toBe(clobbered.raw);
532+
const retryEvents = await readObserveEvents(auditPath);
533+
expect(retryEvents).toHaveLength(2);
534+
expect(retryEvents[1]?.restoredFromBackup).toBe(true);
535+
});
536+
});
537+
457538
it("dedupes repeated suspicious hashes", async () => {
458539
await withSuiteHome(async (home) => {
459540
const { deps, configPath, auditPath } = makeDeps(home);

src/config/io.observe-recovery.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -680,12 +680,14 @@ export async function maybeRecoverSuspiciousConfigRead(params: {
680680
}),
681681
);
682682

683-
healthState = setConfigHealthEntry(
684-
healthState,
685-
params.configPath,
686-
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
687-
);
688-
await writeConfigHealthState(params.deps, healthState);
683+
if (restoredFromBackup) {
684+
healthState = setConfigHealthEntry(
685+
healthState,
686+
params.configPath,
687+
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
688+
);
689+
await writeConfigHealthState(params.deps, healthState);
690+
}
689691
return { raw: backupRaw, parsed: backupParsed };
690692
}
691693

@@ -790,12 +792,14 @@ export function maybeRecoverSuspiciousConfigReadSync(params: {
790792
}),
791793
);
792794

793-
healthState = setConfigHealthEntry(
794-
healthState,
795-
params.configPath,
796-
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
797-
);
798-
writeConfigHealthStateSync(params.deps, healthState);
795+
if (restoredFromBackup) {
796+
healthState = setConfigHealthEntry(
797+
healthState,
798+
params.configPath,
799+
createLastObservedSuspiciousEntry(entry, suspiciousSignature),
800+
);
801+
writeConfigHealthStateSync(params.deps, healthState);
802+
}
799803
return { raw: backupRaw, parsed: backupParsed };
800804
}
801805

0 commit comments

Comments
 (0)