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'); });