mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(config-backup): a restore after a reset keeps the retention, the mode probe cannot write through a planted symlink, the form shows the retention in use
This commit is contained in:
1 parent
c982fa1ce5
commit
241886d44d
7 files changed
+176
-22
No files matched your search
+41
-10
@@ -404,11 +404,30 @@ export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): v
|
||||
|
||||
/** The retention recorded in this instance's newest snapshot, or null when it recorded none. */
|
||||
export function latestBackupRetention(dir: string, origin: string): { keepCount: number; keepDays: number } | null {
|
||||
const [newest] = listConfigBackups(dir, origin);
|
||||
const m = newest ? readManifest(dir, newest.id, origin) : null;
|
||||
return m && typeof m.keepCount === 'number' && typeof m.keepDays === 'number'
|
||||
? { keepCount: m.keepCount, keepDays: m.keepDays }
|
||||
: null;
|
||||
// The newest manifest that recorded one: a snapshot from before this field existed must not hide it.
|
||||
for (const b of listConfigBackups(dir, origin)) {
|
||||
const m = readManifest(dir, b.id, origin);
|
||||
if (m && typeof m.keepCount === 'number' && typeof m.keepDays === 'number') {
|
||||
return { keepCount: m.keepCount, keepDays: m.keepDays };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings in effect when `raw` (the parsed settings.json) has none of the four `configBackup*`
|
||||
* keys, as after a reset: the retention the newest backup recorded instead of the defaults. One
|
||||
* place for the timer pass and for what the GET route reports, so they cannot disagree.
|
||||
*/
|
||||
export function withRecordedRetention(
|
||||
settings: ConfigBackupSettings,
|
||||
raw: Record<string, unknown>,
|
||||
dir: string,
|
||||
origin: string | null
|
||||
): ConfigBackupSettings {
|
||||
if (hasConfigBackupSettings(raw) || !origin) return settings;
|
||||
const kept = latestBackupRetention(dir, origin);
|
||||
return kept ? { ...settings, ...kept } : settings;
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
@@ -473,19 +492,25 @@ function assertTrustedSnapshotEntry(path: string, label: string, checkMode: bool
|
||||
* Whether the backup folder's filesystem keeps a file's mode: vfat/exFAT, CIFS shares and WSL's
|
||||
* `/mnt/d` report 0755 or 0777 for everything, so the group/world test would refuse every backup
|
||||
* there even though snapshots succeed (`createConfigBackup` already expects mode-less filesystems).
|
||||
* Probed with a 0600 temp file; if the probe cannot run, the strict test stays on.
|
||||
* Probed with a 0600 temp file made with `wx` under a random name; if the probe cannot run, the
|
||||
* strict test stays on.
|
||||
*/
|
||||
function folderKeepsModes(dir: string): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
const probe = join(dir, `.mode-probe-${process.pid}`);
|
||||
// A random name and 'wx' (fail if anything is there): a planted symlink or directory with a
|
||||
// guessable name must neither be written through nor break the probe. Only a file this call
|
||||
// created is removed.
|
||||
const probe = join(dir, `.mode-probe-${randomUUID()}`);
|
||||
let created = false;
|
||||
try {
|
||||
writeFileSync(probe, '', { mode: 0o600 });
|
||||
writeFileSync(probe, '', { mode: 0o600, flag: 'wx' });
|
||||
created = true;
|
||||
chmodSync(probe, 0o600);
|
||||
return (lstatSync(probe).mode & 0o777 & 0o077) === 0;
|
||||
} catch {
|
||||
return true;
|
||||
} finally {
|
||||
rmSync(probe, { force: true });
|
||||
if (created) rmSync(probe, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +523,11 @@ function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer {
|
||||
const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>;
|
||||
if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot;
|
||||
let changed = false;
|
||||
// A reset live file has none of the four keys. Taking "absent" for the retention too would bring
|
||||
// the defaults back (20 copies, 30 days) on the next pass, so the backup's own retention stays.
|
||||
const liveHasSettings = hasConfigBackupSettings(live);
|
||||
for (const key of BACKUP_SETTING_KEYS) {
|
||||
if (!liveHasSettings && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays')) continue;
|
||||
if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue;
|
||||
changed = true;
|
||||
if (key in live) restored[key] = live[key];
|
||||
@@ -535,7 +564,7 @@ export function restoreConfigBackup(opts: {
|
||||
}): RestoreResult {
|
||||
const origin = readConfigBackupOrigin(opts.dataDir);
|
||||
const manifest = origin ? readManifest(opts.backupDir, opts.id, origin) : null;
|
||||
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
||||
if (!manifest || !origin) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
||||
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const loaded: Array<{ name: string; data: Buffer }> = [];
|
||||
@@ -563,6 +592,8 @@ export function restoreConfigBackup(opts: {
|
||||
appVersion: opts.appVersion,
|
||||
force: true,
|
||||
now: opts.now,
|
||||
// The retention in force, so this newest snapshot does not hide it from the next pass.
|
||||
...(latestBackupRetention(opts.backupDir, origin) ?? {}),
|
||||
});
|
||||
|
||||
mkdirSync(opts.dataDir, { recursive: true });
|
||||
|
||||
@@ -1388,6 +1388,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const data = body.data;
|
||||
const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir');
|
||||
if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`;
|
||||
// A reset settings.json has none of the four keys, but the passes keep the retention the newest backup
|
||||
// recorded: show that, or a Save would write 20 / 30 over it.
|
||||
const saved = this.loadAppSettingsFromStorage();
|
||||
const countInput = document.getElementById('appSettingsConfigBackupKeepCount');
|
||||
const daysInput = document.getElementById('appSettingsConfigBackupKeepDays');
|
||||
if (countInput && saved.configBackupKeepCount === undefined && data.settings) countInput.value = data.settings.keepCount;
|
||||
if (daysInput && saved.configBackupKeepDays === undefined && data.settings) daysInput.value = data.settings.keepDays;
|
||||
if (!data.backups.length) {
|
||||
out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`;
|
||||
return;
|
||||
|
||||
@@ -40,8 +40,7 @@ import {
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
configBackupOrigin,
|
||||
hasConfigBackupSettings,
|
||||
latestBackupRetention,
|
||||
withRecordedRetention,
|
||||
readConfigBackupOrigin,
|
||||
sweepConfigBackupTemps,
|
||||
type ConfigBackupResult,
|
||||
@@ -73,8 +72,10 @@ export interface ConfigBackupStatus {
|
||||
|
||||
export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> {
|
||||
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
const settings = readConfigBackupSettings(raw);
|
||||
return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) };
|
||||
const base = readConfigBackupSettings(raw);
|
||||
const dir = resolveConfigBackupDir(base, getDataDir());
|
||||
// The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0.
|
||||
return { settings: withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())), dir };
|
||||
}
|
||||
|
||||
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
|
||||
@@ -128,10 +129,7 @@ export async function runConfigBackup(
|
||||
sweepConfigBackupTemps(dir);
|
||||
const origin = configBackupOrigin(getDataDir());
|
||||
// No backup settings in the file (reset, or unreadable): keep the retention the newest snapshot recorded.
|
||||
const settings =
|
||||
unreadable || !hasConfigBackupSettings(raw)
|
||||
? { ...fromFile, ...(latestBackupRetention(dir, origin) ?? {}) }
|
||||
: fromFile;
|
||||
const settings = withRecordedRetention(fromFile, unreadable ? {} : raw, dir, origin);
|
||||
const result = createConfigBackup({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir,
|
||||
|
||||
Reference in new issue
Block a user