diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js
index 41f890d..7c4a169 100644
--- a/lib/diagnostics-collectors.js
+++ b/lib/diagnostics-collectors.js
@@ -79,10 +79,10 @@ function createCollectors(deps) {
readableNames.add(path.basename(fp));
try {
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 {}
}
- 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 = [];
try {
@@ -95,16 +95,16 @@ function createCollectors(deps) {
return { name: f, readable: false, sizeBytes: size, mtime };
});
} catch {}
- return { dir, files, otherLogs: siblings };
+ return { files, otherLogs: siblings };
}
function readLog(args) {
const a = args || {};
const name = a.name;
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 raw = support.collectFile(p, name, tailKb * 1024);
+ const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false });
let content = support.redactLogText(raw, _secrets());
let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
@@ -120,7 +120,7 @@ function createCollectors(deps) {
}
let sizeBytes = null;
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) {
diff --git a/lib/support-bundle.js b/lib/support-bundle.js
index 468b7f3..b646d26 100644
--- a/lib/support-bundle.js
+++ b/lib/support-bundle.js
@@ -26,47 +26,83 @@ function collectSecretValues(config) {
if (typeof o !== 'object') return;
for (const k of Object.keys(o)) {
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);
}
})(config);
return Array.from(out);
}
-function redactLogText(text, secrets) {
- if (typeof text !== 'string' || !text) return text;
+function redactConfiguredSecrets(text, secrets) {
+ 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;
- if (Array.isArray(secrets)) {
- for (const s of secrets) {
- if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
+ for (const value of values) {
+ if (value.length >= 6) {
+ 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(/]*>/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$2')
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '')
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '')
.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(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
- .replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
+ .replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + 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(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\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(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
- .replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/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|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(/("?\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);
return out;
}
function valueScrub(value, secrets) {
if (value === null || value === undefined) return value;
- const json = JSON.stringify(value);
- let scrubbed = json;
- if (Array.isArray(secrets)) {
- for (const s of secrets) {
- if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
- }
+ if (typeof value === 'string') return redactLogText(value, secrets);
+ if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets));
+ if (typeof value === 'object') {
+ const out = {};
+ 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) {
@@ -107,12 +143,12 @@ function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) {
}
parts.push('\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');
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 };
diff --git a/lib/upload-audit.js b/lib/upload-audit.js
index 7d96d38..3819f46 100644
--- a/lib/upload-audit.js
+++ b/lib/upload-audit.js
@@ -1,4 +1,5 @@
const nodePath = require('path');
+const { formatUploadPlanLogLine } = require('./upload-log');
function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) {
if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null;
@@ -12,7 +13,7 @@ function createUploadAuditWriter(options) {
const resolveUploadLogTarget = source.resolveUploadLogTarget;
const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {};
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 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;
@@ -24,27 +25,35 @@ function createUploadAuditWriter(options) {
}
async function append(line, label) {
- let excludedPath = null;
+ const excludedPaths = new Set();
for (const delay of retryDelays) {
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);
if (!targetPath) continue;
try {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
- rotateLogFile(targetPath, maxBytes, maxBackups);
- await fs.promises.appendFile(targetPath, line, 'utf-8');
- activePath = targetPath;
if (uploadTarget.isFallback) {
+ let persisted = false;
try {
- await persistFallbackLogPath(uploadTarget.path);
+ persisted = await persistFallbackLogPath(uploadTarget.path);
} catch (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;
} catch (error) {
- excludedPath = uploadTarget.path;
+ excludedPaths.add(uploadTarget.path);
invalidateUploadLogTarget();
reportError(label, error);
}
@@ -55,4 +64,27 @@ function createUploadAuditWriter(options) {
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 };
diff --git a/main.js b/main.js
index c190f92..3f0c1e0 100644
--- a/main.js
+++ b/main.js
@@ -27,8 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery');
const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy');
-const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log');
-const { getUploadAuditLogPath, createUploadAuditWriter } = require('./lib/upload-audit');
+const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan } = require('./lib/upload-log');
+const { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage } = require('./lib/upload-audit');
const { selectOrphanTmps } = require('./lib/orphan-tmp');
const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
@@ -778,13 +778,14 @@ function _invalidateUploadLogTargetCache() {
_cachedUploadLogKey = '';
}
-function _resolveUploadLogTarget(excludedPath) {
+function _resolveUploadLogTarget(excluded) {
+ const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
const primary = getLogFilePath();
// 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
// process — cache invalidates naturally on path change.
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) => {
_cachedUploadLogTarget = t;
@@ -793,7 +794,7 @@ function _resolveUploadLogTarget(excludedPath) {
};
// Try primary → desktop → userData, mirror the original fallback ladder.
- if (primary !== excludedPath) {
+ if (!excludedPaths.has(primary)) {
try {
fs.mkdirSync(path.dirname(primary), { recursive: true });
return commit({ path: primary, isFallback: false });
@@ -805,7 +806,7 @@ function _resolveUploadLogTarget(excludedPath) {
if (desktop) {
try {
const p = buildFallbackLogName(desktop);
- if (p !== excludedPath) {
+ if (!excludedPaths.has(p)) {
fs.mkdirSync(path.dirname(p), { recursive: true });
return commit({ path: p, isFallback: true });
}
@@ -813,7 +814,7 @@ function _resolveUploadLogTarget(excludedPath) {
}
try {
const p = buildFallbackLogName(app.getPath('userData'));
- if (p === excludedPath) return null;
+ if (excludedPaths.has(p)) return null;
fs.mkdirSync(path.dirname(p), { recursive: true });
return commit({ path: p, isFallback: true });
} catch (err) {
@@ -836,6 +837,7 @@ const _uploadAuditWriter = createUploadAuditWriter({
persistFallbackLogPath: _persistFallbackLogPath,
reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`)
});
+const _uploadAuditEvents = createUploadAuditEvents(_uploadAuditWriter);
function _flushUploadLog() {
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
@@ -936,19 +938,8 @@ function appendUploadLog(hoster, link, fileName) {
}
}
-async function appendUploadAuditLine(line, label) {
- return _uploadAuditWriter.append(line, label);
-}
-
-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');
-}
+const appendSourceCleanupAudit = _uploadAuditEvents.appendSourceCleanup;
+const appendUploadPlanAudit = _uploadAuditEvents.appendUploadPlan;
function flattenHistoryForExport(history) {
const rows = [];
@@ -2032,7 +2023,6 @@ ipcMain.handle('start-upload', async (_event, payload) => {
const tasks = jobs.length > 0
? buildUploadTasksFromJobs(config, jobs, pick)
: buildUploadTasks(config, files, hosters, pick);
- persistRotation(pick);
// Identify jobs that were skipped (no account/credentials)
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}`);
+ 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) {
- await appendUploadPlanAudit(batchPlan, 'start');
const skippedSummary = stats.mergeSkippedIntoSummary({
id: `skipped-${Date.now()}`,
timestamp: new Date().toISOString(),
@@ -2068,12 +2066,10 @@ ipcMain.handle('start-upload', async (_event, payload) => {
return { started: true, taskCount: 0, skippedJobs };
}
- uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
+ uploadManager = auditedStart.value;
globalThis._mhuUploadManagerRef = uploadManager;
const _thisManager = uploadManager;
- await appendUploadPlanAudit(batchPlan, 'start');
-
const recovery = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
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 pick = makeAccountPicker(config);
const tasks = buildUploadTasksFromJobs(config, jobs, pick);
- persistRotation(pick);
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
const skippedJobs = jobs
.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' }));
+ 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
? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
: {};
@@ -2393,7 +2396,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
if (tasks.length === 0) {
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 };
}
@@ -2406,7 +2408,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
debugLog(
`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 };
});
diff --git a/tests/diagnostics-collectors.test.js b/tests/diagnostics-collectors.test.js
index 38daaf8..8cd626f 100644
--- a/tests/diagnostics-collectors.test.js
+++ b/tests/diagnostics-collectors.test.js
@@ -25,7 +25,7 @@ function makeFixture() {
crashLog: path.join(dir, 'crash.log'),
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.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\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', () => {
- const { collectors } = makeFixture();
+ const { collectors, dir, paths } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', 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');
@@ -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.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.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');
});
@@ -104,11 +108,21 @@ test('rotated audit backups are listed and readable with the rotation naming con
const { collectors, paths, fixtureAlpha } = makeFixture();
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
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.every(variant => variant.fileName && !Object.hasOwn(variant, 'path')));
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
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));
});
@@ -171,9 +185,10 @@ test('listErrors classifies via stats.classifyErrorCategory and redacts error te
});
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
- const { collectors } = makeFixture();
+ const { collectors, dir, paths } = makeFixture();
const h = collectors.serverHealth({});
const json = JSON.stringify(h);
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(dir) && !json.includes(paths.debug), 'server_health must not expose absolute log paths');
});
diff --git a/tests/support-bundle.test.js b/tests/support-bundle.test.js
index f1d0ba4..c5d73ec 100644
--- a/tests/support-bundle.test.js
+++ b/tests/support-bundle.test.js
@@ -3,7 +3,7 @@ const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
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', () => {
const input = {
@@ -85,6 +85,38 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
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}`,
+ ``,
+ ``,
+ `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(//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', () => {
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.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/);
});
+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', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
const configuredSecret = ['configured', 'Secret', '123456'].join('');
@@ -200,3 +244,44 @@ test('buildSupportBundleText redacts configured and pattern-detected secrets fro
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);
+ }
+});
diff --git a/tests/upload-audit.test.js b/tests/upload-audit.test.js
index 20a0874..2a12a50 100644
--- a/tests/upload-audit.test.js
+++ b/tests/upload-audit.test.js
@@ -6,13 +6,17 @@ const path = require('path');
test('internal audit records never contaminate the MDU session link log', async () => {
let createUploadAuditWriter;
+ let createUploadAuditEvents;
try {
- ({ createUploadAuditWriter } = require('../lib/upload-audit'));
+ ({ createUploadAuditWriter, createUploadAuditEvents } = require('../lib/upload-audit'));
} catch {}
assert.equal(typeof createUploadAuditWriter, 'function');
+ assert.equal(typeof createUploadAuditEvents, 'function');
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 debugLog = path.join(directory, 'upload-debug.log');
+ fs.writeFileSync(debugLog, 'debug-before\r\n');
const writer = createUploadAuditWriter({
fs,
path,
@@ -22,13 +26,15 @@ test('internal audit records never contaminate the MDU session link log', async
reportError: () => {},
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 writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
+ await events.appendSourceCleanup({ outcome: 'deleted' });
+ await events.appendUploadPlan({ fileCount: 2, destinationCount: 2, plannedUploadCount: 4 }, 'start');
const auditLog = path.join(directory, 'upload-audit.log');
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 });
});
@@ -51,7 +57,7 @@ test('audit writer reports the actual fallback file after a failed primary write
}),
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
- persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
+ persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); return true; },
reportError: () => {},
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');
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:/);
+});