diff --git a/lib/config-store.js b/lib/config-store.js index d17e17c..c0dedbc 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -224,6 +224,15 @@ class ConfigStore { } } + _readHistoryFileStrict() { + const raw = fs.readFileSync(this.historyPath, 'utf-8'); + if (!raw || raw.trim().length < 2) throw new Error('Die Diagnoseverlaufsdatei ist ungültig'); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed; + if (parsed && Array.isArray(parsed.history)) return parsed.history; + throw new Error('Die Diagnoseverlaufsdatei ist ungültig'); + } + _writeHistoryFileDurable(arr) { const tmp = this.historyPath + '.tmp'; const fd = fs.openSync(tmp, 'w'); @@ -380,7 +389,11 @@ class ConfigStore { return r; } - _loadImpl() { + loadDiagnosticsConfig() { + return this._loadImpl(true); + } + + _loadImpl(strict = false) { try { // In-memory cache keyed on the file's mtime+size. The processed config // (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY @@ -392,21 +405,33 @@ class ConfigStore { // long-running main-thread drag. load() always returns a CLONE so callers // can mutate the result without corrupting the cache. let stat = null; - try { stat = fs.statSync(this.filePath); } catch {} + if (!strict) { + try { stat = fs.statSync(this.filePath); } catch {} + } const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : ''; - if (stat && this._cache && this._cacheKey === statKey) { + if (!strict && stat && this._cache && this._cacheKey === statKey) { return this._clone(this._cache); } let data = null; - // Try main config - try { data = this._readAndParse(this.filePath); } catch {} - // Fallback to backup if main is empty/corrupt - if (!data) { - try { data = this._readAndParse(this.filePath + '.bak'); } catch {} - } - if (!data) { - try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {} + if (strict) { + data = this._readAndParse(this.filePath); + if (!data || typeof data !== 'object' || Array.isArray(data) || + !data.hosters || typeof data.hosters !== 'object' || Array.isArray(data.hosters) || + !data.globalSettings || typeof data.globalSettings !== 'object' || Array.isArray(data.globalSettings) || + (data.history !== undefined && !Array.isArray(data.history))) { + throw new Error('Die Diagnosekonfiguration ist ungültig'); + } + } else { + // Try main config + try { data = this._readAndParse(this.filePath); } catch {} + // Fallback to backup if main is empty/corrupt + if (!data) { + try { data = this._readAndParse(this.filePath + '.bak'); } catch {} + } + if (!data) { + try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {} + } } if (!data) { const fresh = JSON.parse(JSON.stringify(DEFAULTS)); @@ -487,13 +512,13 @@ class ConfigStore { // Decrypt credentials stored with safeStorage so the rest of the app // keeps working with plaintext in memory. secretStore.decryptCredentials(result); - if (stat) { + if (!strict && stat) { this._cache = result; this._cacheKey = statKey; } return this._clone(result); } catch (error) { - if (error instanceof secretStore.SecretStoreError) throw error; + if (strict || error instanceof secretStore.SecretStoreError) throw error; const fresh = JSON.parse(JSON.stringify(DEFAULTS)); fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings); return fresh; @@ -707,6 +732,18 @@ class ConfigStore { return config.history || []; } + loadDiagnosticsHistory() { + if (this._historyMigrated) return this._readHistoryFileStrict(); + try { + return this._readHistoryFileStrict(); + } catch (error) { + if (!error || error.code !== 'ENOENT') throw error; + } + const config = this.loadDiagnosticsConfig(); + if (!Array.isArray(config.history)) throw new Error('Der Diagnoseverlauf ist ungültig'); + return config.history; + } + _atomicWrite(data) { return new Promise((resolve, reject) => { const tmpPath = this.filePath + '.tmp'; diff --git a/main.js b/main.js index 62be20b..ac5ec93 100644 --- a/main.js +++ b/main.js @@ -3246,8 +3246,8 @@ function _diagAgentInfo() { function _buildDiagnosticHandler() { const collectors = createCollectors({ - loadConfig: () => configStore.load(), - loadHistory: () => configStore.loadHistory(), + loadConfig: () => configStore.loadDiagnosticsConfig(), + loadHistory: () => configStore.loadDiagnosticsHistory(), getAllLogPaths, support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED }, stats, diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 6462839..cf88ed7 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -666,6 +666,47 @@ describe('ConfigStore', () => { assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup'); }); + it('strict diagnostics config loading rejects primary failures while normal loading keeps recovery', () => { + assert.equal(typeof store.loadDiagnosticsConfig, 'function'); + + fs.writeFileSync(store.filePath + '.bak', JSON.stringify({ + hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] }, + hosterSettings: {}, + globalSettings: {}, + history: [] + }), 'utf-8'); + fs.writeFileSync(store.filePath, '{broken-config', 'utf-8'); + + assert.equal(store.load().hosters['doodstream.com'][0].apiKey, 'from-backup'); + assert.throws(() => store.loadDiagnosticsConfig()); + + fs.rmSync(store.filePath); + assert.equal(store.load().globalSettings.language, 'en'); + assert.throws(() => store.loadDiagnosticsConfig()); + }); + + it('strict diagnostics config loading decrypts current credentials and propagates decryption failures', () => { + assert.equal(typeof store.loadDiagnosticsConfig, 'function'); + + const encrypted = `enc:v1:${Buffer.from('test-protected:diagnostic-secret').toString('base64')}`; + fs.writeFileSync(store.filePath, JSON.stringify({ + hosters: { 'doodstream.com': [{ id: 'diag-1', authType: 'api', apiKey: encrypted }] }, + hosterSettings: {}, + globalSettings: {}, + history: [] + }), 'utf-8'); + + assert.equal(store.loadDiagnosticsConfig().hosters['doodstream.com'][0].apiKey, 'diagnostic-secret'); + + const originalDecryptString = safeStorage.decryptString; + safeStorage.decryptString = () => { throw new Error('diagnostic decrypt failure'); }; + try { + assert.throws(() => store.loadDiagnosticsConfig(), /Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden/); + } finally { + safeStorage.decryptString = originalDecryptString; + } + }); + it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => { // Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts. fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8'); @@ -730,6 +771,46 @@ describe('ConfigStore history split (electron-history.json)', () => { assert.equal(s.loadHistory().length, 30); }); + it('strict diagnostics history accepts valid empty history and rejects failed or corrupt dedicated reads', () => { + assert.equal(typeof s.loadDiagnosticsHistory, 'function'); + writeConfigWithHistory(7); + s._migrateHistory(); + + fs.writeFileSync(s.historyPath, '[]', 'utf-8'); + assert.deepEqual(s.loadDiagnosticsHistory(), []); + + for (const invalidHistory of ['null', '{}', '{"history":null}', '{broken-history']) { + fs.writeFileSync(s.historyPath, invalidHistory, 'utf-8'); + assert.throws(() => s.loadDiagnosticsHistory()); + } + + fs.rmSync(s.historyPath); + fs.mkdirSync(s.historyPath); + assert.throws(() => s.loadDiagnosticsHistory()); + }); + + it('strict diagnostics history never falls back to stale config history when a dedicated file exists', () => { + assert.equal(typeof s.loadDiagnosticsHistory, 'function'); + writeConfigWithHistory(7); + fs.writeFileSync(s.historyPath, 'null', 'utf-8'); + + assert.equal(s._historyMigrated, false); + assert.equal(s.loadHistory().length, 7); + assert.throws(() => s.loadDiagnosticsHistory()); + }); + + it('strict diagnostics history preserves the valid pre-migration history path', () => { + assert.equal(typeof s.loadDiagnosticsHistory, 'function'); + writeConfigWithHistory(7); + + assert.equal(s.loadDiagnosticsHistory().length, 7); + + const config = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')); + config.history = null; + fs.writeFileSync(s.filePath, JSON.stringify(config), 'utf-8'); + assert.throws(() => s.loadDiagnosticsHistory()); + }); + it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => { writeConfigWithHistory(10); s._migrateHistory(); diff --git a/tests/diagnostics-agent.test.js b/tests/diagnostics-agent.test.js index 182e839..b5ab6a3 100644 --- a/tests/diagnostics-agent.test.js +++ b/tests/diagnostics-agent.test.js @@ -107,4 +107,6 @@ test('main process keeps diagnostics local and fails closed at its final reply b assert.match(source, /function _diagAllowlist\(\)\s*{\s*return \[\]/); assert.match(source, /bindMode: 'local'/); assert.match(source, /catch\s*{\s*result = { ok: false, error: 'diagnostic response could not be safely returned' }/); + assert.match(source, /loadConfig:\s*\(\)\s*=>\s*configStore\.loadDiagnosticsConfig\(\)/); + assert.match(source, /loadHistory:\s*\(\)\s*=>\s*configStore\.loadDiagnosticsHistory\(\)/); });