Harden audit durability and diagnostic redaction

Persist fallback audit targets before use and fail upload starts or active-batch additions closed when the plan cannot be recorded. Keep lifecycle audits out of session and debug logs, expose diagnostics through opaque metadata, localize audit failures, and redact paths plus complete credential values without corrupting benign text.
This commit is contained in:
Sucukdeluxe
2026-08-13 20:34:26 +02:00
parent a98b63618d
commit e63214cae8
7 changed files with 372 additions and 77 deletions
+6 -6
View File
@@ -79,10 +79,10 @@ function createCollectors(deps) {
readableNames.add(path.basename(fp)); readableNames.add(path.basename(fp));
try { try {
const st = fs.statSync(fp); const st = fs.statSync(fp);
variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() }); variants.push({ id: backup === 0 ? name : `${name}:${backup}`, backup, fileName: path.basename(fp), sizeBytes: st.size, mtime: st.mtime.toISOString() });
} catch {} } catch {}
} }
files.push({ name, path: base, readable: true, present: variants.length > 0, variants }); files.push({ id: name, name, fileName: path.basename(base), readable: true, present: variants.length > 0, variants });
} }
let siblings = []; let siblings = [];
try { try {
@@ -95,16 +95,16 @@ function createCollectors(deps) {
return { name: f, readable: false, sizeBytes: size, mtime }; return { name: f, readable: false, sizeBytes: size, mtime };
}); });
} catch {} } catch {}
return { dir, files, otherLogs: siblings }; return { files, otherLogs: siblings };
} }
function readLog(args) { function readLog(args) {
const a = args || {}; const a = args || {};
const name = a.name; const name = a.name;
const p = _resolveLogPath(name, a.backup); const p = _resolveLogPath(name, a.backup);
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` }; if (!p) return { ok: false, error: 'unknown or non-readable log identifier' };
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024); const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
const raw = support.collectFile(p, name, tailKb * 1024); const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false });
let content = support.redactLogText(raw, _secrets()); let content = support.redactLogText(raw, _secrets());
let matchedLines; let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) { if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
@@ -120,7 +120,7 @@ function createCollectors(deps) {
} }
let sizeBytes = null; let sizeBytes = null;
try { sizeBytes = fs.statSync(p).size; } catch {} try { sizeBytes = fs.statSync(p).size; } catch {}
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content }; return { id: name, name, fileName: path.basename(p), sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
} }
function getAppEvents(args) { function getAppEvents(args) {
+59 -23
View File
@@ -26,47 +26,83 @@ function collectSecretValues(config) {
if (typeof o !== 'object') return; if (typeof o !== 'object') return;
for (const k of Object.keys(o)) { for (const k of Object.keys(o)) {
const v = o[k]; const v = o[k];
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v); if (CRED_KEYS.has(k) && typeof v === 'string' && v.length > 0) out.add(v);
else walk(v); else walk(v);
} }
})(config); })(config);
return Array.from(out); return Array.from(out);
} }
function redactLogText(text, secrets) { function redactConfiguredSecrets(text, secrets) {
if (typeof text !== 'string' || !text) return text; if (!Array.isArray(secrets)) return text;
const values = Array.from(new Set(secrets.filter(value => typeof value === 'string' && value.length > 0)))
.sort((a, b) => b.length - a.length);
let out = text; let out = text;
if (Array.isArray(secrets)) { for (const value of values) {
for (const s of secrets) { if (value.length >= 6) {
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED); out = out.split(value).join(REDACTED);
continue;
}
let offset = 0;
while (offset < out.length) {
const index = out.indexOf(value, offset);
if (index < 0) break;
const first = value[0];
const last = value[value.length - 1];
const before = index > 0 ? out[index - 1] : '';
const after = index + value.length < out.length ? out[index + value.length] : '';
const identifier = character => /[A-Za-z0-9_]/.test(character);
if ((!identifier(first) || !identifier(before)) && (!identifier(last) || !identifier(after))) {
out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`;
offset = index + REDACTED.length;
} else {
offset = index + value.length;
}
} }
} }
out = out return out;
}
function redactHtmlCredentialFields(text) {
return text.replace(/<input\b[^>]*>/gi, input => {
const sensitive = /\btype\s*=\s*["']?password\b/i.test(input)
|| /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input);
if (!sensitive) return input;
return input
.replace(/(\bvalue\s*=\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
.replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`);
});
}
function redactLogText(text, secrets) {
if (typeof text !== 'string' || !text) return text;
let out = redactConfiguredSecrets(text, secrets);
out = redactHtmlCredentialFields(out)
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2') .replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2')
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>') .replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>')
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>') .replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>')
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED) .replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2') .replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED) .replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED) .replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
.replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+\-/=]+/gi, '$1' + REDACTED)
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED) .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED) .replace(/([?&](?:api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|auth|authorization|password|pass|cookie|session(?:[_-]?id)?)=)[^\s&#"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED) .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED) .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(?!["'])([^\s,;}\]\r\n]+)/gi, '$1' + REDACTED);
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
return out; return out;
} }
function valueScrub(value, secrets) { function valueScrub(value, secrets) {
if (value === null || value === undefined) return value; if (value === null || value === undefined) return value;
const json = JSON.stringify(value); if (typeof value === 'string') return redactLogText(value, secrets);
let scrubbed = json; if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets));
if (Array.isArray(secrets)) { if (typeof value === 'object') {
for (const s of secrets) { const out = {};
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED); for (const [key, entry] of Object.entries(value)) out[key] = valueScrub(entry, secrets);
} return out;
} }
return JSON.parse(scrubbed); return value;
} }
function collectFile(filePath, label, maxBytes, options) { function collectFile(filePath, label, maxBytes, options) {
@@ -107,12 +143,12 @@ function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) {
} }
parts.push('\n'); parts.push('\n');
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n'); parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
parts.push(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets)); parts.push(JSON.stringify(sanitizedConfig, null, 2));
parts.push('\n\n'); parts.push('\n\n');
for (const f of (files || [])) { for (const f of (files || [])) {
parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets)); parts.push(collectFile(f.path, f.label || 'log', f.maxBytes, { includePath: false }));
} }
return parts.join(''); return redactLogText(parts.join(''), secrets);
} }
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
+41 -9
View File
@@ -1,4 +1,5 @@
const nodePath = require('path'); const nodePath = require('path');
const { formatUploadPlanLogLine } = require('./upload-log');
function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) { function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) {
if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null; if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null;
@@ -12,7 +13,7 @@ function createUploadAuditWriter(options) {
const resolveUploadLogTarget = source.resolveUploadLogTarget; const resolveUploadLogTarget = source.resolveUploadLogTarget;
const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {}; const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {};
const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {}; const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {};
const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => {}; const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => false;
const reportError = typeof source.reportError === 'function' ? source.reportError : () => {}; const reportError = typeof source.reportError === 'function' ? source.reportError : () => {};
const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250]; const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250];
const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024; const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024;
@@ -24,27 +25,35 @@ function createUploadAuditWriter(options) {
} }
async function append(line, label) { async function append(line, label) {
let excludedPath = null; const excludedPaths = new Set();
for (const delay of retryDelays) { for (const delay of retryDelays) {
if (delay) await new Promise(resolve => setTimeout(resolve, delay)); if (delay) await new Promise(resolve => setTimeout(resolve, delay));
const uploadTarget = resolveUploadLogTarget(excludedPath); const uploadTarget = resolveUploadLogTarget(excludedPaths);
if (!uploadTarget || excludedPaths.has(uploadTarget.path)) continue;
const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path); const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path);
if (!targetPath) continue; if (!targetPath) continue;
try { try {
fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.mkdirSync(path.dirname(targetPath), { recursive: true });
rotateLogFile(targetPath, maxBytes, maxBackups);
await fs.promises.appendFile(targetPath, line, 'utf-8');
activePath = targetPath;
if (uploadTarget.isFallback) { if (uploadTarget.isFallback) {
let persisted = false;
try { try {
await persistFallbackLogPath(uploadTarget.path); persisted = await persistFallbackLogPath(uploadTarget.path);
} catch (error) { } catch (error) {
reportError('audit-fallback-persist', error); reportError('audit-fallback-persist', error);
} }
if (persisted !== true) {
excludedPaths.add(uploadTarget.path);
invalidateUploadLogTarget();
reportError('audit-fallback-persist', new Error('Fallback log path could not be persisted'));
continue;
}
} }
rotateLogFile(targetPath, maxBytes, maxBackups);
await fs.promises.appendFile(targetPath, line, 'utf-8');
activePath = targetPath;
return true; return true;
} catch (error) { } catch (error) {
excludedPath = uploadTarget.path; excludedPaths.add(uploadTarget.path);
invalidateUploadLogTarget(); invalidateUploadLogTarget();
reportError(label, error); reportError(label, error);
} }
@@ -55,4 +64,27 @@ function createUploadAuditWriter(options) {
return { append, getActivePath: () => activePath }; return { append, getActivePath: () => activePath };
} }
module.exports = { getUploadAuditLogPath, createUploadAuditWriter }; function createUploadAuditEvents(writer, now = () => new Date()) {
if (!writer || typeof writer.append !== 'function') throw new TypeError('createUploadAuditEvents requires an audit writer');
return {
appendSourceCleanup: event => writer.append(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'),
appendUploadPlan: (plan, mode) => writer.append(formatUploadPlanLogLine(now(), plan, mode), 'upload-plan')
};
}
async function runAfterDurableAudit(audit, action) {
let persisted = false;
try {
persisted = await audit();
} catch {}
if (persisted !== true) return { ok: false };
return { ok: true, value: await action() };
}
function getUploadAuditFailureMessage(language) {
return language === 'de'
? 'Der Uploadplan konnte nicht dauerhaft protokolliert werden. Bitte prüfe den Log-Pfad und versuche es erneut.'
: 'The upload plan could not be recorded durably. Check the log path and try again.';
}
module.exports = { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage };
+29 -28
View File
@@ -27,8 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery');
const RemoteServer = require('./lib/remote-server'); const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation'); const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { hosterLogToFileEnabled } = require('./lib/log-policy');
const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log'); const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan } = require('./lib/upload-log');
const { getUploadAuditLogPath, createUploadAuditWriter } = require('./lib/upload-audit'); const { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage } = require('./lib/upload-audit');
const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { selectOrphanTmps } = require('./lib/orphan-tmp');
const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
@@ -778,13 +778,14 @@ function _invalidateUploadLogTargetCache() {
_cachedUploadLogKey = ''; _cachedUploadLogKey = '';
} }
function _resolveUploadLogTarget(excludedPath) { function _resolveUploadLogTarget(excluded) {
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
const primary = getLogFilePath(); const primary = getLogFilePath();
// The primary path already encodes the mode + date/session, so it changes // The primary path already encodes the mode + date/session, so it changes
// when the user toggles mode, daily rolls at midnight, or this is a new // when the user toggles mode, daily rolls at midnight, or this is a new
// process — cache invalidates naturally on path change. // process — cache invalidates naturally on path change.
const key = primary; const key = primary;
if (_cachedUploadLogKey === key && _cachedUploadLogTarget && _cachedUploadLogTarget.path !== excludedPath) return _cachedUploadLogTarget; if (_cachedUploadLogKey === key && _cachedUploadLogTarget && !excludedPaths.has(_cachedUploadLogTarget.path)) return _cachedUploadLogTarget;
const commit = (t) => { const commit = (t) => {
_cachedUploadLogTarget = t; _cachedUploadLogTarget = t;
@@ -793,7 +794,7 @@ function _resolveUploadLogTarget(excludedPath) {
}; };
// Try primary → desktop → userData, mirror the original fallback ladder. // Try primary → desktop → userData, mirror the original fallback ladder.
if (primary !== excludedPath) { if (!excludedPaths.has(primary)) {
try { try {
fs.mkdirSync(path.dirname(primary), { recursive: true }); fs.mkdirSync(path.dirname(primary), { recursive: true });
return commit({ path: primary, isFallback: false }); return commit({ path: primary, isFallback: false });
@@ -805,7 +806,7 @@ function _resolveUploadLogTarget(excludedPath) {
if (desktop) { if (desktop) {
try { try {
const p = buildFallbackLogName(desktop); const p = buildFallbackLogName(desktop);
if (p !== excludedPath) { if (!excludedPaths.has(p)) {
fs.mkdirSync(path.dirname(p), { recursive: true }); fs.mkdirSync(path.dirname(p), { recursive: true });
return commit({ path: p, isFallback: true }); return commit({ path: p, isFallback: true });
} }
@@ -813,7 +814,7 @@ function _resolveUploadLogTarget(excludedPath) {
} }
try { try {
const p = buildFallbackLogName(app.getPath('userData')); const p = buildFallbackLogName(app.getPath('userData'));
if (p === excludedPath) return null; if (excludedPaths.has(p)) return null;
fs.mkdirSync(path.dirname(p), { recursive: true }); fs.mkdirSync(path.dirname(p), { recursive: true });
return commit({ path: p, isFallback: true }); return commit({ path: p, isFallback: true });
} catch (err) { } catch (err) {
@@ -836,6 +837,7 @@ const _uploadAuditWriter = createUploadAuditWriter({
persistFallbackLogPath: _persistFallbackLogPath, persistFallbackLogPath: _persistFallbackLogPath,
reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`) reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`)
}); });
const _uploadAuditEvents = createUploadAuditEvents(_uploadAuditWriter);
function _flushUploadLog() { function _flushUploadLog() {
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return; if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
@@ -936,19 +938,8 @@ function appendUploadLog(hoster, link, fileName) {
} }
} }
async function appendUploadAuditLine(line, label) { const appendSourceCleanupAudit = _uploadAuditEvents.appendSourceCleanup;
return _uploadAuditWriter.append(line, label); const appendUploadPlanAudit = _uploadAuditEvents.appendUploadPlan;
}
async function appendSourceCleanupAudit(event) {
debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`);
return appendUploadAuditLine(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup');
}
async function appendUploadPlanAudit(plan, mode) {
debugLog(`upload-plan: mode=${mode} files=${plan.fileCount} destinations=${plan.destinationCount} uploads=${plan.plannedUploadCount}`);
return appendUploadAuditLine(formatUploadPlanLogLine(new Date(), plan, mode), 'upload-plan');
}
function flattenHistoryForExport(history) { function flattenHistoryForExport(history) {
const rows = []; const rows = [];
@@ -2032,7 +2023,6 @@ ipcMain.handle('start-upload', async (_event, payload) => {
const tasks = jobs.length > 0 const tasks = jobs.length > 0
? buildUploadTasksFromJobs(config, jobs, pick) ? buildUploadTasksFromJobs(config, jobs, pick)
: buildUploadTasks(config, files, hosters, pick); : buildUploadTasks(config, files, hosters, pick);
persistRotation(pick);
// Identify jobs that were skipped (no account/credentials) // Identify jobs that were skipped (no account/credentials)
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
@@ -2050,8 +2040,16 @@ ipcMain.handle('start-upload', async (_event, payload) => {
debugLog(` tasks built: ${tasks.length}`); debugLog(` tasks built: ${tasks.length}`);
const auditedStart = await runAfterDurableAudit(
() => appendUploadPlanAudit(batchPlan, 'start'),
() => tasks.length > 0 ? new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)) : null
);
if (!auditedStart.ok) {
return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
}
persistRotation(pick);
if (tasks.length === 0) { if (tasks.length === 0) {
await appendUploadPlanAudit(batchPlan, 'start');
const skippedSummary = stats.mergeSkippedIntoSummary({ const skippedSummary = stats.mergeSkippedIntoSummary({
id: `skipped-${Date.now()}`, id: `skipped-${Date.now()}`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -2068,12 +2066,10 @@ ipcMain.handle('start-upload', async (_event, payload) => {
return { started: true, taskCount: 0, skippedJobs }; return { started: true, taskCount: 0, skippedJobs };
} }
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); uploadManager = auditedStart.value;
globalThis._mhuUploadManagerRef = uploadManager; globalThis._mhuUploadManagerRef = uploadManager;
const _thisManager = uploadManager; const _thisManager = uploadManager;
await appendUploadPlanAudit(batchPlan, 'start');
const recovery = { const recovery = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
@@ -2376,11 +2372,18 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : [];
const pick = makeAccountPicker(config); const pick = makeAccountPicker(config);
const tasks = buildUploadTasksFromJobs(config, jobs, pick); const tasks = buildUploadTasksFromJobs(config, jobs, pick);
persistRotation(pick);
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
const skippedJobs = jobs const skippedJobs = jobs
.filter(j => j && j.id && !taskJobIds.has(j.id)) .filter(j => j && j.id && !taskJobIds.has(j.id))
.map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' }));
if (jobs.length > 0) {
const auditedAdd = await runAfterDurableAudit(
() => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'),
() => true
);
if (!auditedAdd.ok) return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
}
persistRotation(pick);
const sourceCleanupFingerprints = batchManager.sourceFileCleanup const sourceCleanupFingerprints = batchManager.sourceFileCleanup
? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
: {}; : {};
@@ -2393,7 +2396,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
if (tasks.length === 0) { if (tasks.length === 0) {
debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`);
if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add');
return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints }; return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints };
} }
@@ -2406,7 +2408,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
debugLog( debugLog(
`add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)`
); );
if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add');
return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints }; return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints };
}); });
+20 -5
View File
@@ -25,7 +25,7 @@ function makeFixture() {
crashLog: path.join(dir, 'crash.log'), crashLog: path.join(dir, 'crash.log'),
logDir: dir logDir: dir
}; };
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`); fs.writeFileSync(paths.debug, `boot ok\nsource ${path.join(dir, 'private-source.mkv')}\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`); fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`); fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n'); fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
@@ -88,7 +88,7 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
}); });
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => { test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture(); const { collectors, dir, paths } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 }); const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 }); const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 });
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs'); assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
@@ -97,6 +97,10 @@ test('readLog redacts a planted token and a Bearer line; doodstream is NOT reada
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path'); assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist'); assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)'); assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.ok(!JSON.stringify(dbg).includes(dir), 'read log content and metadata must not expose its absolute directory');
const rejectedAbsolutePath = collectors.readLog({ name: paths.debug });
assert.ok(!rejectedAbsolutePath.error.includes(paths.debug), 'rejected log identifiers must not be echoed as absolute paths');
assert.ok(!rejectedAbsolutePath.error.includes(path.basename(paths.debug)), 'rejected log identifiers must not echo path components');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash'); assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
}); });
@@ -104,11 +108,21 @@ test('rotated audit backups are listed and readable with the rotation naming con
const { collectors, paths, fixtureAlpha } = makeFixture(); const { collectors, paths, fixtureAlpha } = makeFixture();
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log'); const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`); fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit'); const logList = collectors.listLogs();
const listed = logList.files.find(file => file.name === 'uploadAudit');
assert.equal(logList.dir, undefined);
assert.equal(listed.id, 'uploadAudit');
assert.equal(listed.fileName, 'upload-audit.log');
assert.equal(listed.path, undefined);
assert.ok(listed.variants.some(variant => variant.backup === 1)); assert.ok(listed.variants.some(variant => variant.backup === 1));
assert.ok(listed.variants.every(variant => variant.fileName && !Object.hasOwn(variant, 'path')));
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log')); assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 }); const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
assert.equal(backup.path, backupPath); assert.equal(backup.id, 'uploadAudit');
assert.equal(backup.name, 'uploadAudit');
assert.equal(backup.fileName, 'upload-audit.1.log');
assert.equal(backup.path, undefined);
assert.ok(!JSON.stringify({ logList, backup }).includes(path.dirname(paths.uploadAudit)));
assert.ok(!backup.content.includes(fixtureAlpha)); assert.ok(!backup.content.includes(fixtureAlpha));
}); });
@@ -171,9 +185,10 @@ test('listErrors classifies via stats.classifyErrorCategory and redacts error te
}); });
test('serverHealth assembles the one-shot hub without leaking secrets', () => { test('serverHealth assembles the one-shot hub without leaking secrets', () => {
const { collectors } = makeFixture(); const { collectors, dir, paths } = makeFixture();
const h = collectors.serverHealth({}); const h = collectors.serverHealth({});
const json = JSON.stringify(h); const json = JSON.stringify(h);
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections'); assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health'); assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
assert.ok(!json.includes(dir) && !json.includes(paths.debug), 'server_health must not expose absolute log paths');
}); });
+86 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle'); const { sanitizeConfig, collectSecretValues, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => { test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = { const input = {
@@ -85,6 +85,38 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
assert.equal(redactLogText(benign, []), benign); assert.equal(redactLogText(benign, []), benign);
}); });
test('redactLogText removes complete authorization, cookie, session, HTML credential, and query values', () => {
const authorization = 'Digest username="private-user", realm="private-realm", response="private-response"';
const cookie = 'sid=private-cookie; preferences=private-preferences';
const sessionId = 's3';
const htmlPassword = 'private-html-password';
const htmlToken = 'private-html-token';
const queryToken = 'q1';
const input = [
`Authorization: ${authorization}`,
`Cookie: ${cookie}`,
`session_id=${sessionId}`,
`<input type="password" name="password" value="${htmlPassword}">`,
`<input value="${htmlToken}" name="api_token" type="text">`,
`https://example.invalid/upload?token=${queryToken}&next=ok`
].join('\n');
const out = redactLogText(input, []);
for (const value of [authorization, 'private-user', 'private-realm', 'private-response', cookie, 'private-cookie', 'private-preferences', sessionId, htmlPassword, htmlToken, queryToken]) {
assert.ok(!out.includes(value), `sensitive value survived: ${value}`);
}
assert.ok((out.match(/<redacted>/g) || []).length >= 6);
});
test('redactLogText masks a one-character configured secret only as a complete sensitive value', () => {
const out = redactLogText('status=diagnostics available\npassword=x\nfile=xylophone.mkv\nmarker=x', ['x']);
assert.ok(out.includes('status=diagnostics available'));
assert.ok(out.includes('file=xylophone.mkv'));
assert.ok(!out.includes('password=x'));
assert.ok(!out.includes('marker=x'));
assert.ok(out.includes(`password=${REDACTED}`));
assert.ok(out.includes(`marker=${REDACTED}`));
});
test('redactLogText removes complete local paths from structured and free-form log text', () => { test('redactLogText removes complete local paths from structured and free-form log text', () => {
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\'); const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\');
const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\'); const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\');
@@ -172,6 +204,18 @@ test('buildSupportBundleText handles empty file list and missing header', () =>
assert.match(text, /=== Config/); assert.match(text, /=== Config/);
}); });
test('buildSupportBundleText never uses an absolute source path as a section label', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-unlabeled-${Date.now()}.log`);
fs.writeFileSync(tmp, 'safe content\n');
try {
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [{ path: tmp }], secrets: [] });
assert.ok(!text.includes(tmp));
assert.ok(text.includes('=== log (size='));
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => { test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`); const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
const configuredSecret = ['configured', 'Secret', '123456'].join(''); const configuredSecret = ['configured', 'Secret', '123456'].join('');
@@ -200,3 +244,44 @@ test('buildSupportBundleText redacts configured and pattern-detected secrets fro
fs.unlinkSync(tmp); fs.unlinkSync(tmp);
} }
}); });
test('buildSupportBundleText removes configured secrets of every non-empty length', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-short-secrets-${Date.now()}.log`);
const config = {
hosters: { 'voe.sx': [{ password: 'p1', apiKey: 'k2' }] },
globalSettings: { diagnostics: { token: 't3' }, cookie: '', sessionId: null }
};
fs.writeFileSync(tmp, 'password=p1\napiKey=k2\ntoken=t3\n');
try {
const secrets = collectSecretValues(config);
assert.deepEqual(new Set(secrets), new Set(['p1', 'k2', 't3']));
const text = buildSupportBundleText({
header: { Marker: 'p1-k2-t3' },
sanitizedConfig: sanitizeConfig(config),
secrets,
files: [{ label: 'short-secrets.log', path: tmp }]
});
for (const secret of ['p1', 'k2', 't3']) assert.ok(!text.includes(secret), `configured secret survived: ${secret}`);
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText removes a one-character configured secret', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-one-character-secret-${Date.now()}.log`);
const config = { hosters: { 'voe.sx': [{ password: 'x' }] } };
fs.writeFileSync(tmp, 'password=x\n');
try {
const text = buildSupportBundleText({
header: { Marker: 'secret:x' },
sanitizedConfig: sanitizeConfig(config),
secrets: collectSecretValues(config),
files: [{ label: 'one-character.log', path: tmp }]
});
assert.ok(!text.includes('secret:x'));
assert.ok(!text.includes('password=x'));
assert.ok(text.includes('one-character.log'));
} finally {
fs.unlinkSync(tmp);
}
});
+131 -5
View File
@@ -6,13 +6,17 @@ const path = require('path');
test('internal audit records never contaminate the MDU session link log', async () => { test('internal audit records never contaminate the MDU session link log', async () => {
let createUploadAuditWriter; let createUploadAuditWriter;
let createUploadAuditEvents;
try { try {
({ createUploadAuditWriter } = require('../lib/upload-audit')); ({ createUploadAuditWriter, createUploadAuditEvents } = require('../lib/upload-audit'));
} catch {} } catch {}
assert.equal(typeof createUploadAuditWriter, 'function'); assert.equal(typeof createUploadAuditWriter, 'function');
assert.equal(typeof createUploadAuditEvents, 'function');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-'));
const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log'); const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log');
const debugLog = path.join(directory, 'upload-debug.log');
fs.writeFileSync(debugLog, 'debug-before\r\n');
const writer = createUploadAuditWriter({ const writer = createUploadAuditWriter({
fs, fs,
path, path,
@@ -22,13 +26,15 @@ test('internal audit records never contaminate the MDU session link log', async
reportError: () => {}, reportError: () => {},
retryDelays: [0] retryDelays: [0]
}); });
const events = createUploadAuditEvents(writer, () => new Date('2026-08-13T12:00:00.000Z'));
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup'); await events.appendSourceCleanup({ outcome: 'deleted' });
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan'); await events.appendUploadPlan({ fileCount: 2, destinationCount: 2, plannedUploadCount: 4 }, 'start');
const auditLog = path.join(directory, 'upload-audit.log'); const auditLog = path.join(directory, 'upload-audit.log');
assert.equal(fs.existsSync(sessionLog), false); assert.equal(fs.existsSync(sessionLog), false);
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"plannedUploadCount":4}\r\n'); assert.equal(fs.readFileSync(debugLog, 'utf8'), 'debug-before\r\n');
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"timestamp":"2026-08-13T12:00:00.000Z","mode":"start","fileCount":2,"destinationCount":2,"plannedUploadCount":4}\r\n');
fs.rmSync(directory, { recursive: true, force: true }); fs.rmSync(directory, { recursive: true, force: true });
}); });
@@ -51,7 +57,7 @@ test('audit writer reports the actual fallback file after a failed primary write
}), }),
rotateLogFile: () => {}, rotateLogFile: () => {},
invalidateUploadLogTarget: () => {}, invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); }, persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); return true; },
reportError: () => {}, reportError: () => {},
retryDelays: [0, 0] retryDelays: [0, 0]
}); });
@@ -62,3 +68,123 @@ test('audit writer reports the actual fallback file after a failed primary write
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n'); assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
fs.rmSync(directory, { recursive: true, force: true }); fs.rmSync(directory, { recursive: true, force: true });
}); });
test('audit writer rejects false and thrown fallback persistence before trying the next safe target', async (t) => {
const { createUploadAuditWriter } = require('../lib/upload-audit');
for (const rejection of ['false', 'throw']) {
await t.test(rejection, async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${rejection}-`));
const first = path.join(directory, 'first', 'fileuploader.log');
const second = path.join(directory, 'second', 'fileuploader.log');
const targets = [first, second].map(targetPath => ({ path: targetPath, isFallback: true }));
const persistedFallbacks = [];
const writer = createUploadAuditWriter({
fs,
path,
resolveUploadLogTarget: excluded => {
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
return targets.find(target => !excludedPaths.has(target.path)) || null;
},
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => {
persistedFallbacks.push(targetPath);
if (targetPath === first) {
if (rejection === 'throw') throw new Error('settings write failed');
return false;
}
return true;
},
reportError: () => {},
retryDelays: [0, 0, 0]
});
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
assert.equal(writer.getActivePath(), path.join(directory, 'second', 'upload-audit.log'));
assert.deepEqual(persistedFallbacks, [first, second]);
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
fs.rmSync(directory, { recursive: true, force: true });
});
}
});
test('audit writer returns false only after every allowed fallback target is rejected', async () => {
const { createUploadAuditWriter } = require('../lib/upload-audit');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-exhausted-'));
const targets = ['first', 'second'].map(name => ({ path: path.join(directory, name, 'fileuploader.log'), isFallback: true }));
const persistedFallbacks = [];
const writer = createUploadAuditWriter({
fs,
path,
resolveUploadLogTarget: excluded => {
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
return targets.find(target => !excludedPaths.has(target.path)) || null;
},
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => {
persistedFallbacks.push(targetPath);
if (persistedFallbacks.length === 2) throw new Error('settings write failed');
return false;
},
reportError: () => {},
retryDelays: [0, 0, 0]
});
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
assert.deepEqual(persistedFallbacks, targets.map(target => target.path));
assert.equal(writer.getActivePath(), null);
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
assert.equal(fs.existsSync(path.join(directory, 'second', 'upload-audit.log')), false);
fs.rmSync(directory, { recursive: true, force: true });
});
test('durable audit gate never creates a manager or adds jobs after a false or thrown audit', async () => {
const { runAfterDurableAudit } = require('../lib/upload-audit');
assert.equal(typeof runAfterDurableAudit, 'function');
for (const actionName of ['manager', 'addJobs']) {
for (const audit of [async () => false, async () => { throw new Error('audit failed'); }]) {
let actions = 0;
const result = await runAfterDurableAudit(audit, () => { actions += 1; return actionName; });
assert.equal(result.ok, false);
assert.equal(actions, 0);
}
}
let actions = 0;
const result = await runAfterDurableAudit(async () => true, () => { actions += 1; return 'started'; });
assert.deepEqual(result, { ok: true, value: 'started' });
assert.equal(actions, 1);
});
test('audit failure message is localized and tells the user how to retry', () => {
const { getUploadAuditFailureMessage } = require('../lib/upload-audit');
const german = getUploadAuditFailureMessage('de');
const english = getUploadAuditFailureMessage('en');
assert.match(german, /Log-Pfad/);
assert.match(german, /erneut/);
assert.match(english, /log path/i);
assert.match(english, /try again/i);
assert.notEqual(german, english);
});
test('main process audits batch plans before creating or mutating upload work', () => {
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const startHandler = mainSource.slice(
mainSource.indexOf("ipcMain.handle('start-upload'"),
mainSource.indexOf("ipcMain.handle('cancel-upload'")
);
const addHandler = mainSource.slice(
mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"),
mainSource.indexOf("ipcMain.handle('finish-after-active'")
);
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('new UploadManager('));
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('persistRotation(pick)'));
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('persistRotation(pick)'));
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('registerGroups(sourceCleanupGroups)'));
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('batchManager.addJobs(tasks)'));
assert.doesNotMatch(mainSource, /debugLog\(`source-cleanup:/);
assert.doesNotMatch(mainSource, /debugLog\(`upload-plan:/);
});