From d21b721de479193105f12c1557276bb8b86d93e8 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:27:13 +0200 Subject: [PATCH] fix: keep internal audit logs in user data Route upload audit and account rotation output through one internal userData log resolver with a contained fallback directory. Report active writer paths to diagnostics and log reveal actions while preserving the configurable fileuploader.log fallback contract and leaving existing Desktop files untouched. Cover primary paths, fallback containment, rotation, active path reporting, log opening, and upload log isolation with focused tests. --- lib/upload-audit.js | 78 +++++++++++++------- main.js | 61 ++++++++-------- tests/upload-audit.test.js | 144 +++++++++++++++++++++++++------------ 3 files changed, 178 insertions(+), 105 deletions(-) diff --git a/lib/upload-audit.js b/lib/upload-audit.js index 7d96d38..eade08c 100644 --- a/lib/upload-audit.js +++ b/lib/upload-audit.js @@ -1,58 +1,84 @@ const nodePath = require('path'); -function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) { - if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null; - return pathApi.join(pathApi.dirname(uploadLogPath), 'upload-audit.log'); -} - -function createUploadAuditWriter(options) { +function createInternalLogPathResolver(options) { const source = options && typeof options === 'object' ? options : {}; const fs = source.fs; const path = source.path || nodePath; - const resolveUploadLogTarget = source.resolveUploadLogTarget; + const userDataPath = typeof source.userDataPath === 'string' ? source.userDataPath.trim() : ''; + + if (!fs || typeof fs.mkdirSync !== 'function' || !userDataPath) { + throw new TypeError('createInternalLogPathResolver requires fs and userDataPath'); + } + + const directories = [path.join(userDataPath, 'logs'), path.join(userDataPath, 'internal-logs')]; + + return function resolveInternalLogPath(fileName, excludedPaths = new Set()) { + if (typeof fileName !== 'string' || !fileName || path.basename(fileName) !== fileName) return null; + const excluded = excludedPaths instanceof Set + ? excludedPaths + : new Set(Array.isArray(excludedPaths) ? excludedPaths : [excludedPaths]); + for (const directory of directories) { + const targetPath = path.join(directory, fileName); + if (excluded.has(targetPath)) continue; + try { + fs.mkdirSync(directory, { recursive: true }); + return targetPath; + } catch {} + } + return null; + }; +} + +function createInternalLogWriter(options) { + const source = options && typeof options === 'object' ? options : {}; + const fs = source.fs; + const path = source.path || nodePath; + const fileName = source.fileName; + const resolveInternalLogPath = source.resolveInternalLogPath; 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 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; const maxBackups = Number.isFinite(source.maxBackups) ? source.maxBackups : 2; let activePath = null; - if (!fs || !fs.promises || typeof fs.promises.appendFile !== 'function' || typeof resolveUploadLogTarget !== 'function') { - throw new TypeError('createUploadAuditWriter requires fs and resolveUploadLogTarget'); + if (!fs || !fs.promises || typeof fs.promises.appendFile !== 'function' || typeof resolveInternalLogPath !== 'function' || typeof fileName !== 'string') { + throw new TypeError('createInternalLogWriter requires fs, fileName and resolveInternalLogPath'); } 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 targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path); - if (!targetPath) continue; + const targetPath = resolveInternalLogPath(fileName, excludedPaths); + if (!targetPath) break; + excludedPaths.add(targetPath); 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) { - try { - await persistFallbackLogPath(uploadTarget.path); - } catch (error) { - reportError('audit-fallback-persist', error); - } - } return true; } catch (error) { - excludedPath = uploadTarget.path; - invalidateUploadLogTarget(); reportError(label, error); } } return false; } - return { append, getActivePath: () => activePath }; + return { + append, + getActivePath: () => activePath, + getPath: () => activePath || resolveInternalLogPath(fileName) + }; } -module.exports = { getUploadAuditLogPath, createUploadAuditWriter }; +function createUploadAuditWriter(options) { + return createInternalLogWriter({ ...options, fileName: 'upload-audit.log' }); +} + +function getLogOpenDirectory(targetPath, fallbackDirectory, pathApi = nodePath) { + return typeof targetPath === 'string' && targetPath ? pathApi.dirname(targetPath) : fallbackDirectory; +} + +module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory }; diff --git a/main.js b/main.js index 0299406..3ddff45 100644 --- a/main.js +++ b/main.js @@ -30,7 +30,7 @@ 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 { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory } = 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'); @@ -419,6 +419,21 @@ let _debugLogWriting = false; const DEBUG_LOG_MAX_BYTES = 25 * 1024 * 1024; const ROT_LOG_MAX_BYTES = 10 * 1024 * 1024; const INTERNAL_LOG_MAX_BACKUPS = 2; +const _resolveInternalLogPath = createInternalLogPathResolver({ + fs, + path, + userDataPath: app.getPath('userData') +}); +const _rotLogWriter = createInternalLogWriter({ + fs, + path, + fileName: 'account-rotation.log', + resolveInternalLogPath: _resolveInternalLogPath, + rotateLogFile: maybeRotateLogFile, + maxBytes: ROT_LOG_MAX_BYTES, + maxBackups: INTERNAL_LOG_MAX_BACKUPS, + reportError: (label, error) => debugLog(`${label} append failed: ${error.message}`) +}); function _flushDebugLog() { if (_debugLogWriting || _debugLogBuffer.length === 0) return; @@ -538,13 +553,8 @@ function _maybeLogEventLoopDelay(activeJobs) { } catch {} } -// Dedicated account-rotation log so users can trace fallback decisions -// without wading through general debug output. Writes to account-rotation.log -// in the same directory as fileuploader.log (honors user's configured path). function getRotLogPath() { - const base = getLogFilePath(); - const dir = path.dirname(base); - return path.join(dir, 'account-rotation.log'); + return _rotLogWriter.getPath(); } const _rotLogBuffer = []; let _rotLogFlushTimer = null; @@ -555,26 +565,15 @@ function _flushRotLog() { const chunk = _rotLogBuffer.join(''); _rotLogBuffer.length = 0; _rotLogWriting = true; - const tryTargets = [ - getRotLogPath(), - path.join(app.getPath('desktop') || app.getPath('userData'), 'account-rotation.log'), - path.join(app.getPath('userData'), 'account-rotation.log') - ]; - const write = (i) => { - if (i >= tryTargets.length) { _rotLogWriting = false; return; } - try { - fs.mkdirSync(path.dirname(tryTargets[i]), { recursive: true }); - } catch {} - // Cap account-rotation.log so a long-running install can't keep - // growing it indefinitely (rotation events fire on every account-fail). - maybeRotateLogFile(tryTargets[i], ROT_LOG_MAX_BYTES, INTERNAL_LOG_MAX_BACKUPS, debugLog); - fs.appendFile(tryTargets[i], chunk, 'utf-8', (err) => { - if (err) return write(i + 1); - _rotLogWriting = false; - if (_rotLogBuffer.length) setImmediate(_flushRotLog); - }); - }; - write(0); + _rotLogWriter.append(chunk, 'rot-log').then(written => { + _rotLogWriting = false; + if (!written) debugLog('rot-log append failed: no writable target'); + if (_rotLogBuffer.length) setImmediate(_flushRotLog); + }, error => { + _rotLogWriting = false; + debugLog(`rot-log append failed: ${error.message}`); + if (_rotLogBuffer.length) setImmediate(_flushRotLog); + }); } function getAllLogPaths() { @@ -586,7 +585,7 @@ function getAllLogPaths() { const dir = path.dirname(debugPath); return { fileuploader: upload, - uploadAudit: _uploadAuditWriter.getActivePath() || getUploadAuditLogPath(upload), + uploadAudit: _uploadAuditWriter.getPath(), debug: debugPath, accountRotation: rot, doodstreamDebug: path.join(dir, 'doodstream-debug.log'), @@ -912,10 +911,8 @@ const UPLOAD_LOG_MAX_BACKUPS = 3; const _uploadAuditWriter = createUploadAuditWriter({ fs, path, - resolveUploadLogTarget: _resolveUploadLogTarget, + resolveInternalLogPath: _resolveInternalLogPath, rotateLogFile: maybeRotateLogFile, - invalidateUploadLogTarget: _invalidateUploadLogTargetCache, - persistFallbackLogPath: _persistFallbackLogPath, reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`) }); @@ -2666,7 +2663,7 @@ ipcMain.handle('reveal-log-file', async (_event, target) => { shell.showItemInFolder(file); return { ok: true, path: file }; } - const dir = paths.logDir; + const dir = getLogOpenDirectory(file, paths.logDir, path); if (dir) { fs.mkdirSync(dir, { recursive: true }); shell.openPath(dir); diff --git a/tests/upload-audit.test.js b/tests/upload-audit.test.js index 20a0874..41add76 100644 --- a/tests/upload-audit.test.js +++ b/tests/upload-audit.test.js @@ -4,61 +4,111 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -test('internal audit records never contaminate the MDU session link log', async () => { - let createUploadAuditWriter; - try { - ({ createUploadAuditWriter } = require('../lib/upload-audit')); - } catch {} - assert.equal(typeof createUploadAuditWriter, 'function'); +const { + createInternalLogPathResolver, + createInternalLogWriter, + createUploadAuditWriter, + getLogOpenDirectory +} = require('../lib/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 writer = createUploadAuditWriter({ - fs, - path, - resolveUploadLogTarget: () => ({ path: sessionLog, isFallback: false }), - rotateLogFile: () => {}, - invalidateUploadLogTarget: () => {}, - reportError: () => {}, - retryDelays: [0] - }); +function createTempDirectory(t, prefix) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} - await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup'); - await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan'); +test('internal audit and account rotation paths share the userData logs directory', (t) => { + const directory = createTempDirectory(t, 'mhu-internal-log-paths-'); + const userDataPath = path.join(directory, 'user-data'); + const resolveInternalLogPath = createInternalLogPathResolver({ fs, path, userDataPath }); - 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'); - fs.rmSync(directory, { recursive: true, force: true }); + assert.equal(resolveInternalLogPath('upload-audit.log'), path.join(userDataPath, 'logs', 'upload-audit.log')); + assert.equal(resolveInternalLogPath('account-rotation.log'), path.join(userDataPath, 'logs', 'account-rotation.log')); }); -test('audit writer reports the actual fallback file after a failed primary write', async () => { - const { createUploadAuditWriter } = require('../lib/upload-audit'); - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-fallback-')); - const blockedParent = path.join(directory, 'blocked'); - const fallbackDirectory = path.join(directory, 'fallback'); - fs.writeFileSync(blockedParent, 'not a directory'); - let attempts = 0; - const persistedFallbacks = []; - const writer = createUploadAuditWriter({ - fs, +test('internal log paths fall back to another directory below userData without touching Desktop files', (t) => { + const directory = createTempDirectory(t, 'mhu-internal-log-fallback-'); + const userDataPath = path.join(directory, 'user-data'); + const desktopPath = path.join(directory, 'Desktop'); + const desktopAuditPath = path.join(desktopPath, 'upload-audit.log'); + const desktopRotationPath = path.join(desktopPath, 'account-rotation.log'); + fs.mkdirSync(userDataPath, { recursive: true }); + fs.mkdirSync(desktopPath, { recursive: true }); + fs.writeFileSync(path.join(userDataPath, 'logs'), 'blocked'); + fs.writeFileSync(desktopAuditPath, 'existing audit'); + fs.writeFileSync(desktopRotationPath, 'existing rotation'); + + const resolveInternalLogPath = createInternalLogPathResolver({ fs, path, userDataPath }); + + assert.equal(resolveInternalLogPath('upload-audit.log'), path.join(userDataPath, 'internal-logs', 'upload-audit.log')); + assert.equal(resolveInternalLogPath('account-rotation.log'), path.join(userDataPath, 'internal-logs', 'account-rotation.log')); + assert.equal(fs.readFileSync(desktopAuditPath, 'utf8'), 'existing audit'); + assert.equal(fs.readFileSync(desktopRotationPath, 'utf8'), 'existing rotation'); +}); + +test('internal rotation writer retries inside userData and reports the file that accepted the write', async (t) => { + const directory = createTempDirectory(t, 'mhu-internal-log-writer-'); + const userDataPath = path.join(directory, 'user-data'); + const primaryPath = path.join(userDataPath, 'logs', 'account-rotation.log'); + const fallbackPath = path.join(userDataPath, 'internal-logs', 'account-rotation.log'); + const appendTargets = []; + const rotationTargets = []; + const testFs = { + mkdirSync: fs.mkdirSync, + promises: { + appendFile: async (targetPath, ...args) => { + appendTargets.push(targetPath); + if (targetPath === primaryPath) throw Object.assign(new Error('blocked primary'), { code: 'EACCES' }); + return fs.promises.appendFile(targetPath, ...args); + } + } + }; + const targets = [primaryPath, fallbackPath]; + const resolveInternalLogPath = (_fileName, excludedPaths = new Set()) => targets.find(targetPath => !excludedPaths.has(targetPath)) || null; + const writer = createInternalLogWriter({ + fs: testFs, path, - resolveUploadLogTarget: () => ({ - path: attempts++ === 0 - ? path.join(blockedParent, 'fileuploader.log') - : path.join(fallbackDirectory, 'fileuploader.log'), - isFallback: attempts > 1 - }), - rotateLogFile: () => {}, - invalidateUploadLogTarget: () => {}, - persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); }, + fileName: 'account-rotation.log', + resolveInternalLogPath, + rotateLogFile: targetPath => rotationTargets.push(targetPath), reportError: () => {}, retryDelays: [0, 0] }); - assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true); - assert.equal(writer.getActivePath(), path.join(fallbackDirectory, 'upload-audit.log')); - assert.deepEqual(persistedFallbacks, [path.join(fallbackDirectory, 'fileuploader.log')]); - assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n'); - fs.rmSync(directory, { recursive: true, force: true }); + assert.equal(await writer.append('[rotation]\n', 'rot-log'), true); + assert.deepEqual(appendTargets, [primaryPath, fallbackPath]); + assert.deepEqual(rotationTargets, [primaryPath, fallbackPath]); + assert.equal(writer.getActivePath(), fallbackPath); + assert.equal(fs.readFileSync(fallbackPath, 'utf8'), '[rotation]\n'); +}); + +test('upload audit writer leaves the configured fileuploader log contract unchanged', async (t) => { + const directory = createTempDirectory(t, 'mhu-upload-log-contract-'); + const userDataPath = path.join(directory, 'user-data'); + const customUploadDirectory = path.join(directory, 'custom-upload-logs'); + const uploadLogPath = path.join(customUploadDirectory, '13-08-2026-mdu-session-14-20-123456.log'); + fs.mkdirSync(customUploadDirectory, { recursive: true }); + fs.writeFileSync(uploadLogPath, 'existing upload entry\n'); + const resolveInternalLogPath = () => path.join(userDataPath, 'logs', 'upload-audit.log'); + const writer = createUploadAuditWriter({ + fs, + path, + resolveInternalLogPath, + rotateLogFile: () => {}, + reportError: () => {}, + retryDelays: [0] + }); + + assert.equal(await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan'), true); + assert.equal(writer.getActivePath(), path.join(userDataPath, 'logs', 'upload-audit.log')); + assert.equal(fs.readFileSync(uploadLogPath, 'utf8'), 'existing upload entry\n'); + assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {"plannedUploadCount":4}\r\n'); +}); + +test('log opening uses the reported internal file directory before the general fallback directory', () => { + assert.equal( + getLogOpenDirectory('C:\\AppData\\Multi-Hoster\\logs\\upload-audit.log', 'C:\\Program Files\\Multi-Hoster', path.win32), + 'C:\\AppData\\Multi-Hoster\\logs' + ); + assert.equal(getLogOpenDirectory(null, 'C:\\Program Files\\Multi-Hoster', path.win32), 'C:\\Program Files\\Multi-Hoster'); });