Skip to content

Commit f8f939c

Browse files
davidangularmejalehman
authored andcommitted
fix(config): surface backup restore copy failures in audit and logs
Previously a failed copyFile during suspicious-read recovery was swallowed by a bare catch, still logged as 'Config auto-restored from backup', and the audit record was written with valid: true — making a silent failure look like success. Capture the error, log a distinct failure warning with the message, set valid to restoredFromBackup, and persist restoreErrorCode / restoreErrorMessage on the observe audit record for both async and sync paths.
1 parent 595fca4 commit f8f939c

4 files changed

Lines changed: 121 additions & 10 deletions

File tree

src/config/io.audit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export type ConfigObserveAuditRecord = {
227227
clobberedPath: string | null;
228228
restoredFromBackup: boolean;
229229
restoredBackupPath: string | null;
230+
restoreErrorCode: string | null;
231+
restoreErrorMessage: string | null;
230232
};
231233

232234
export type ConfigAuditRecord = ConfigWriteAuditRecord | ConfigObserveAuditRecord;

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,44 @@ describe("config observe recovery", () => {
278278
});
279279
});
280280

281+
it("records copyFile failure instead of falsely claiming restore succeeded", async () => {
282+
await withSuiteHome(async (home) => {
283+
const { deps, configPath, auditPath, warn } = makeDeps(home);
284+
await seedConfigBackup(configPath, recoverableTelegramConfig);
285+
const clobbered = await writeClobberedUpdateChannel(configPath);
286+
287+
const copyError = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
288+
const failingFs: ObserveRecoveryDeps["fs"] = {
289+
...deps.fs,
290+
promises: {
291+
...deps.fs.promises,
292+
copyFile: () => Promise.reject(copyError),
293+
},
294+
};
295+
const recovered = await maybeRecoverSuspiciousConfigRead({
296+
deps: { ...deps, fs: failingFs },
297+
configPath,
298+
raw: clobbered.raw,
299+
parsed: clobbered.parsed,
300+
});
301+
302+
expect((recovered.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
303+
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(clobbered.raw);
304+
expect(warn).toHaveBeenCalledWith(
305+
expect.stringContaining("Config auto-restore from backup failed:"),
306+
);
307+
expect(warn).not.toHaveBeenCalledWith(
308+
expect.stringContaining("Config auto-restored from backup:"),
309+
);
310+
311+
const observe = await readLastObserveEvent(auditPath);
312+
expect(observe?.restoredFromBackup).toBe(false);
313+
expect(observe?.valid).toBe(false);
314+
expect(observe?.restoreErrorCode).toBe("EACCES");
315+
expect(observe?.restoreErrorMessage).toBe("EACCES: permission denied");
316+
});
317+
});
318+
281319
it("dedupes repeated suspicious hashes", async () => {
282320
await withSuiteHome(async (home) => {
283321
const { deps, configPath, auditPath } = makeDeps(home);

src/config/io.observe-recovery.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ function createConfigObserveAuditRecord(params: {
127127
clobberedPath: string | null;
128128
restoredFromBackup: boolean;
129129
restoredBackupPath: string | null;
130+
restoreErrorCode?: string | null;
131+
restoreErrorMessage?: string | null;
130132
}): ConfigObserveAuditRecord {
131133
return {
132134
ts: params.ts,
@@ -175,6 +177,8 @@ function createConfigObserveAuditRecord(params: {
175177
clobberedPath: params.clobberedPath,
176178
restoredFromBackup: params.restoredFromBackup,
177179
restoredBackupPath: params.restoredBackupPath,
180+
restoreErrorCode: params.restoreErrorCode ?? null,
181+
restoreErrorMessage: params.restoreErrorMessage ?? null,
178182
};
179183
}
180184

@@ -192,6 +196,35 @@ function createConfigObserveAuditAppendParams(
192196
};
193197
}
194198

199+
function extractRestoreErrorDetails(error: unknown): {
200+
code: string | null;
201+
message: string | null;
202+
} {
203+
if (!error || typeof error !== "object") {
204+
return { code: null, message: typeof error === "string" ? error : null };
205+
}
206+
const code =
207+
"code" in error && typeof (error as { code?: unknown }).code === "string"
208+
? (error as { code: string }).code
209+
: null;
210+
const message =
211+
"message" in error && typeof (error as { message?: unknown }).message === "string"
212+
? (error as { message: string }).message
213+
: null;
214+
return { code, message };
215+
}
216+
217+
function createConfigObserveAnomalyAuditAppendParams(
218+
deps: ObserveRecoveryDeps,
219+
params: Omit<ConfigObserveAuditRecordParams, "restoredFromBackup" | "restoredBackupPath">,
220+
) {
221+
return createConfigObserveAuditAppendParams(deps, {
222+
...params,
223+
restoredFromBackup: false,
224+
restoredBackupPath: null,
225+
});
226+
}
227+
195228
function hashConfigRaw(raw: string | null): string {
196229
return crypto
197230
.createHash("sha256")
@@ -637,26 +670,43 @@ export async function maybeRecoverSuspiciousConfigRead(params: {
637670
});
638671

639672
let restoredFromBackup = false;
673+
let restoreError: unknown;
640674
try {
641675
await params.deps.fs.promises.copyFile(backupPath, params.configPath);
642676
restoredFromBackup = true;
643-
} catch {}
677+
} catch (error) {
678+
restoreError = error;
679+
}
644680

645-
params.deps.logger.warn(
646-
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
647-
);
681+
const restoreErrorDetails = restoredFromBackup
682+
? { code: null, message: null }
683+
: extractRestoreErrorDetails(restoreError);
684+
685+
if (restoredFromBackup) {
686+
params.deps.logger.warn(
687+
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
688+
);
689+
} else {
690+
params.deps.logger.warn(
691+
`Config auto-restore from backup failed: ${params.configPath} (${suspicious.join(", ")})${
692+
restoreErrorDetails.message ? `: ${restoreErrorDetails.message}` : ""
693+
}`,
694+
);
695+
}
648696
await appendConfigAuditRecord(
649697
createConfigObserveAuditAppendParams(params.deps, {
650698
ts: now,
651699
configPath: params.configPath,
652-
valid: true,
700+
valid: restoredFromBackup,
653701
current,
654702
suspicious,
655703
lastKnownGood: entry.lastKnownGood,
656704
backup,
657705
clobberedPath,
658706
restoredFromBackup,
659707
restoredBackupPath: backupPath,
708+
restoreErrorCode: restoreErrorDetails.code,
709+
restoreErrorMessage: restoreErrorDetails.message,
660710
}),
661711
);
662712

@@ -727,26 +777,43 @@ export function maybeRecoverSuspiciousConfigReadSync(params: {
727777
});
728778

729779
let restoredFromBackup = false;
780+
let restoreError: unknown;
730781
try {
731782
params.deps.fs.copyFileSync(backupPath, params.configPath);
732783
restoredFromBackup = true;
733-
} catch {}
784+
} catch (error) {
785+
restoreError = error;
786+
}
734787

735-
params.deps.logger.warn(
736-
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
737-
);
788+
const restoreErrorDetails = restoredFromBackup
789+
? { code: null, message: null }
790+
: extractRestoreErrorDetails(restoreError);
791+
792+
if (restoredFromBackup) {
793+
params.deps.logger.warn(
794+
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
795+
);
796+
} else {
797+
params.deps.logger.warn(
798+
`Config auto-restore from backup failed: ${params.configPath} (${suspicious.join(", ")})${
799+
restoreErrorDetails.message ? `: ${restoreErrorDetails.message}` : ""
800+
}`,
801+
);
802+
}
738803
appendConfigAuditRecordSync(
739804
createConfigObserveAuditAppendParams(params.deps, {
740805
ts: now,
741806
configPath: params.configPath,
742-
valid: true,
807+
valid: restoredFromBackup,
743808
current,
744809
suspicious,
745810
lastKnownGood: entry.lastKnownGood,
746811
backup,
747812
clobberedPath,
748813
restoredFromBackup,
749814
restoredBackupPath: backupPath,
815+
restoreErrorCode: restoreErrorDetails.code,
816+
restoreErrorMessage: restoreErrorDetails.message,
750817
}),
751818
);
752819

src/config/io.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,8 @@ async function observeConfigSnapshot(
765765
clobberedPath,
766766
restoredFromBackup: false,
767767
restoredBackupPath: null,
768+
restoreErrorCode: null,
769+
restoreErrorMessage: null,
768770
},
769771
});
770772

@@ -895,6 +897,8 @@ function observeConfigSnapshotSync(
895897
clobberedPath,
896898
restoredFromBackup: false,
897899
restoredBackupPath: null,
900+
restoreErrorCode: null,
901+
restoreErrorMessage: null,
898902
},
899903
});
900904

0 commit comments

Comments
 (0)