From a7fbe22e624521ea6f0d25463c6a479d0bfe1caa Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:13:02 -0400 Subject: [PATCH] Re-read the config file before every write Writes loaded the config from the 5 second read cache. A file broken by hand inside that window was overwritten by the cached copy, with no corrupt-file backup, and a valid hand edit was lost. saveConfig now checks the file itself before writing, so a broken file is backed up and kept. loadConfigForUpdate and the config route's stale-write check read the file, not the cache. --- api/src/config.js | 36 ++++++++++++++++++++++----------- api/src/routes/config.js | 2 +- api/test/config-corrupt.test.js | 28 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/api/src/config.js b/api/src/config.js index 22610801..72a8d1eb 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -211,21 +211,14 @@ function _backupCorrupt(raw) { return _lastBackup; } -/* Returns the live cache. Do not write to it. Call loadConfigForUpdate to - change the config. A write here survives a failed save. */ -function loadConfig() { - if (IS_DEMO) return loadDemoConfig(); - - const now = Date.now(); - if (_cfgCache && now - _cfgCacheAt < CONFIG_TTL_MS) return _cfgCache; - +function _readDisk() { let raw; try { raw = fs.readFileSync(CONFIG_PATH, 'utf8'); } catch (e) { if (e.code === 'ENOENT') _clearDamage(); else _markDamaged('unreadable', e.message); - return migrate({ items: [], settings: {} }); + return null; } let parsed; @@ -233,13 +226,13 @@ function loadConfig() { parsed = JSON.parse(raw); } catch (e) { _markDamaged('corrupt', e.message, _backupCorrupt(raw)); - return migrate({ items: [], settings: {} }); + return null; } const shaped = _normalizeShape(parsed); if (!shaped) { _markDamaged('corrupt', 'wrong shape', _backupCorrupt(raw)); - return migrate({ items: [], settings: {} }); + return null; } _clearDamage(); @@ -254,6 +247,20 @@ function loadConfig() { ...(backup ? { backup } : {}), }); } + return shaped; +} + +/* Returns the live cache. Do not write to it. Call loadConfigForUpdate to + change the config. A write here survives a failed save. */ +function loadConfig() { + if (IS_DEMO) return loadDemoConfig(); + + const now = Date.now(); + if (_cfgCache && now - _cfgCacheAt < CONFIG_TTL_MS) return _cfgCache; + + const shaped = _readDisk(); + if (!shaped) return migrate({ items: [], settings: {} }); + const before = shaped._schemaVersion; migrate(shaped); _cfgCache = shaped; @@ -269,14 +276,19 @@ function loadConfig() { return shaped; } +/* Reads the file, not the cache. A hand edit made in the cache window would + otherwise be overwritten by the next save. */ function loadConfigForUpdate() { + _cfgCacheAt = 0; return structuredClone(loadConfig()); } /* keepRev is only for writes the browser never sees. Every open dashboard reloads when _rev changes. */ function saveConfig(data, { keepRev = false } = {}) { - if (_damage) loadConfig(); + /* Check the file, not the cache. A file broken after the last read must be + kept, not overwritten. */ + _readDisk(); if (_damage) throw new Error('config file cannot be used; refusing to overwrite it'); if (data && typeof data === 'object') { data._schemaVersion = SCHEMA_VERSION; diff --git a/api/src/routes/config.js b/api/src/routes/config.js index d3555727..3707c37d 100644 --- a/api/src/routes/config.js +++ b/api/src/routes/config.js @@ -168,7 +168,7 @@ on('POST', '/api/config', async (req, res) => { if ('keepAwake' in data.settings) data.settings.keepAwake = data.settings.keepAwake === true; if ('typeToSearch' in data.settings) data.settings.typeToSearch = data.settings.typeToSearch !== false; } - const existing = loadConfig(); + const existing = loadConfigForUpdate(); /* Stale-write check. A client that sends no _rev overwrites. */ if (data._rev != null && Number(data._rev) !== (Number(existing._rev) || 0)) return json(res, 409, { diff --git a/api/test/config-corrupt.test.js b/api/test/config-corrupt.test.js index 3d8d1de6..3073a671 100644 --- a/api/test/config-corrupt.test.js +++ b/api/test/config-corrupt.test.js @@ -151,3 +151,31 @@ test('item entries that are not objects are skipped and the file is backed up', assert.equal(b.length, 1); assert.equal(fs.readFileSync(path.join(DIR, b[0]), 'utf8'), raw); }); + +test('a file broken after the last read is kept, not overwritten by the next save', () => { + reset(); + const cfg = fresh(); + cfg.saveConfig({ items: [{ id: 'a', type: 'app' }], settings: {} }); + cfg.loadConfig(); + fs.writeFileSync(CFG, '{ edited by hand'); + assert.throws(() => cfg.saveConfig(cfg.loadConfigForUpdate())); + assert.equal(fs.readFileSync(CFG, 'utf8'), '{ edited by hand'); + assert.equal(backups().length, 1); +}); + +test('a hand edit made after the last read survives a read-modify-write', () => { + reset(); + const cfg = fresh(); + cfg.saveConfig({ items: [], settings: { theme: 'dark' } }); + cfg.loadConfig(); + fs.writeFileSync( + CFG, + JSON.stringify({ _schemaVersion: cfg.SCHEMA_VERSION, items: [], settings: { theme: 'light' } }), + ); + const next = cfg.loadConfigForUpdate(); + next.settings.language = 'de'; + cfg.saveConfig(next); + const onDisk = JSON.parse(fs.readFileSync(CFG, 'utf8')); + assert.equal(onDisk.settings.theme, 'light'); + assert.equal(onDisk.settings.language, 'de'); +});