From 4147be2a2dcb49fcd6ab50be310439c6b1785598 Mon Sep 17 00:00:00 2001 From: trgv Date: Fri, 26 Jun 2026 13:47:17 +0300 Subject: [PATCH 1/3] fix(security): harden WSL command execution against injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses five security findings from an audit of the WSL command-execution layer, plus a rendering-consistency fix: 1. Command injection via distro name (detectTools) — replaced the lone execSync shell-string with execFileSync('wsl', [...]). 2. Path injection in disk scan (scanDiskUsage, runs as root) — the treemap target path is now shell-quoted via a new shellQuote helper. 3. Predictable temp-script names — helper scripts (several run as root in WSL) now use random filenames via uniqueTempPath, closing a local TOCTOU/symlink race and avoiding concurrent collisions. 4. Electron navigation hardening — added setWindowOpenHandler and a will-navigate guard in main.js. 5. runWslCommand executable allowlist — refuses any command whose executable isn't `wsl`. Also: fail-log rendering now uses escapeHtml consistently. Adds tests/security.test.js (11 regression tests). Full suite: 191 passing. This branch also carries in-progress reliability work bundled by request: UTF-16 output decoding, transient WSL-service-timeout retry, and registry-based VHDX discovery. --- lib/wsl-ops.js | 267 +++++++++++++++++++++++++++++++++-------- locales/en.json | 1 + main.js | 96 +++++++++++++-- package-lock.json | 18 +-- renderer/app.js | 40 +++--- renderer/styles.css | 20 ++- renderer/tasks.js | 8 +- tests/security.test.js | 121 +++++++++++++++++++ 8 files changed, 482 insertions(+), 89 deletions(-) create mode 100644 tests/security.test.js diff --git a/lib/wsl-ops.js b/lib/wsl-ops.js index 5694c7e..91069ad 100644 --- a/lib/wsl-ops.js +++ b/lib/wsl-ops.js @@ -1,18 +1,87 @@ // ── Shared WSL operations (used by both Electron main process and CLI) ─────── -const { spawn, execSync, execFile } = require('child_process'); +const { spawn, execSync, execFileSync, execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); const fs = require('fs'); const os = require('os'); const path = require('path'); +const crypto = require('crypto'); const { filterNoise, parseWslOutput, friendlyError } = require('./utils'); +/** + * Build a unique temp-file path for a helper script/output. + * Using a random suffix (instead of a fixed name) prevents a local attacker + * from pre-creating/swapping the file between write and execution — some of + * these scripts run as root inside WSL — and avoids collisions when two + * operations run concurrently. + * @param {string} label Short descriptor, e.g. "health" + * @param {string} ext Extension without the dot, e.g. "sh" + * @returns {string} + */ +function uniqueTempPath(label, ext) { + const tempDir = process.env.TEMP || os.tmpdir(); + const rand = crypto.randomBytes(8).toString('hex'); + return path.join(tempDir, `wsl-cleaner-${label}-${rand}.${ext}`); +} + // Suppress "bogus screen size" warnings from WSL and prevent BASH_ENV from // sourcing init scripts that may hijack the bash process (e.g. systemd // namespace wrappers that exec into nsenter, swallowing script output). const wslEnv = { ...process.env, TERM: 'dumb', COLUMNS: '120', LINES: '40', BASH_ENV: '' }; +/** + * Decode stdout/stderr buffers that may be UTF-8 or UTF-16LE. + * Windows tools (and sometimes `wsl.exe`) can emit UTF-16LE on some locales. + * @param {Buffer|string} data + */ +function decodeText(data) { + if (typeof data === 'string') return data; + if (!Buffer.isBuffer(data)) return String(data ?? ''); + + const sample = data.subarray(0, Math.min(data.length, 2048)); + + const score = (s) => { + // Lower score is better. Penalize NULs, replacement chars, and most control chars. + let bad = 0; + let good = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 0) { bad += 8; continue; } + if (c === 0xfffd) { bad += 4; continue; } // � replacement + if (c < 0x20 && c !== 0x0a && c !== 0x0d && c !== 0x09) { bad += 2; continue; } + // printable-ish + good += 1; + } + return bad - good * 0.05; + }; + + let utf8 = ''; + let utf16 = ''; + try { utf8 = sample.toString('utf8'); } catch { utf8 = ''; } + try { utf16 = sample.toString('utf16le'); } catch { utf16 = ''; } + + // Fast path: if lots of NULs in utf8, it's almost certainly mis-decoded UTF-16LE + const utf8Nuls = (utf8.match(/\u0000/g) || []).length; + if (utf8Nuls > 2) return data.toString('utf16le'); + + // Choose the decoding that looks "more human" + const s8 = score(utf8); + const s16 = score(utf16); + return s16 < s8 ? data.toString('utf16le') : data.toString('utf8'); +} + +/** + * Quote a string for safe interpolation into a POSIX shell (bash) command. + * Wraps in single quotes and escapes embedded single quotes, so the value is + * always treated as a single literal argument — never as shell syntax. + * @param {string} value + * @returns {string} + */ +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + // ── WSL detection ──────────────────────────────────────────────────────────── /** @@ -74,7 +143,10 @@ function detectTools(distro) { const tools = {}; for (const check of TOOL_CHECKS) { try { - execSync(`wsl -d ${distro} -- bash -lc "${check.cmd} 2>/dev/null"`, { + // Pass args as an array (no shell) so a distro name containing shell + // metacharacters cannot inject commands into the host. See SAFE_UNIT_RE + // for the analogous defence on systemd unit names. + execFileSync('wsl', ['-d', distro, '--', 'bash', '-lc', `${check.cmd} 2>/dev/null`], { encoding: 'utf8', timeout: 10000, env: wslEnv, stdio: ['pipe', 'pipe', 'pipe'], // suppress stderr (bogus screen-size warnings) }); @@ -99,7 +171,9 @@ function detectTools(distro) { * @returns {Promise<{ ok: boolean, output: string, code: number }>} */ function runCleanupTask({ distro, taskId, command, asRoot, onOutput }) { - return new Promise((resolve) => { + const isWslServiceTimeout = (text) => /Wsl\/Service\/0x8007274c/i.test(text || ''); + + const runOnce = () => new Promise((resolve) => { const args = ['-d', distro]; if (asRoot) args.push('-u', 'root'); args.push('--', 'bash', '-lc', command); @@ -108,14 +182,14 @@ function runCleanupTask({ distro, taskId, command, asRoot, onOutput }) { let fullOutput = ''; proc.stdout.on('data', (data) => { - const text = filterNoise(data.toString()); + const text = filterNoise(decodeText(data)); if (!text) return; fullOutput += text; if (onOutput) onOutput({ taskId, text }); }); proc.stderr.on('data', (data) => { - const text = filterNoise(data.toString()); + const text = filterNoise(decodeText(data)); if (!text) return; fullOutput += text; if (onOutput) onOutput({ taskId, text }); @@ -129,29 +203,82 @@ function runCleanupTask({ distro, taskId, command, asRoot, onOutput }) { resolve({ ok: false, output: friendlyError(err.message), code: -1 }); }); }); + + return (async () => { + const first = await runOnce(); + if (first.ok) return first; + + // Transient WSL service timeout: retry once for robustness. + if (isWslServiceTimeout(first.output)) { + if (onOutput) { + onOutput({ + taskId, + text: '\n[WSL] Connection timed out (Wsl/Service/0x8007274c). Retrying once...\n', + }); + } + await new Promise(r => setTimeout(r, 1500)); + const second = await runOnce(); + if (second.ok) return second; + + // If still failing, prepend a clear hint (works in UI + file log) + const hint = 'WSL service connection timed out (Wsl/Service/0x8007274c). Try: `wsl --shutdown`, then start the distro and rerun.\n'; + return { ...second, output: hint + (second.output || '') }; + } + + return first; + })(); } // ── VHDX file discovery ────────────────────────────────────────────────────── /** * Find ext4.vhdx files on the host filesystem. + * Supports: + * - Microsoft Store distros under %LOCALAPPDATA%\Packages\...\LocalState\ext4.vhdx + * - Docker Desktop WSL disks + * - `wsl --import` / moved distros via registry BasePath (HKCU\...\Lxss) * @returns {Array<{ path: string, size: number, folder: string }>} */ -function findVhdx() { +function findVhdx(distroName) { const results = []; + const byPath = new Map(); // path -> { idx, folder } const localAppData = process.env.LOCALAPPDATA; const userProfile = process.env.USERPROFILE; + const addIfExists = (vhdxPath, folder) => { + try { + if (!vhdxPath) return; + if (!fs.existsSync(vhdxPath)) return; + const stats = fs.statSync(vhdxPath); + if (!stats.isFile()) return; + + const existing = byPath.get(vhdxPath); + if (existing) { + // Prefer human distro names over package ids / generic labels + const currentFolder = results[existing.idx]?.folder || existing.folder || ''; + const shouldReplace = + (folder && folder !== currentFolder) && + (currentFolder === 'Docker' || /^HKEY_/i.test(currentFolder) || /_8wekyb3d8bbwe$/i.test(currentFolder) || currentFolder.length > folder.length); + if (shouldReplace) { + results[existing.idx].folder = folder; + } + // Keep size updated (should be same file) + results[existing.idx].size = stats.size; + return; + } + + results.push({ path: vhdxPath, size: stats.size, folder }); + byPath.set(vhdxPath, { idx: results.length - 1, folder }); + } catch { /* ignore */ } + }; + if (localAppData) { const packagesDir = path.join(localAppData, 'Packages'); try { const entries = fs.readdirSync(packagesDir); for (const entry of entries) { const localStatePath = path.join(packagesDir, entry, 'LocalState', 'ext4.vhdx'); - if (fs.existsSync(localStatePath)) { - const stats = fs.statSync(localStatePath); - results.push({ path: localStatePath, size: stats.size, folder: entry }); - } + addIfExists(localStatePath, entry); } } catch { /* ignore */ } } @@ -168,8 +295,7 @@ function findVhdx() { if (entry.isDirectory()) { walkDir(fullPath); } else if (entry.name === 'ext4.vhdx') { - const stats = fs.statSync(fullPath); - results.push({ path: fullPath, size: stats.size, folder: 'Docker' }); + addIfExists(fullPath, 'Docker'); } } }; @@ -178,25 +304,62 @@ function findVhdx() { } catch { /* ignore */ } } - // Custom install locations in user profile + // Registry-discovered BasePath (covers wsl --import and moved distros) + try { + const out = execSync( + // Note: `reg query` can't take multiple `/v` flags, so query everything and parse. + 'reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss" /s', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ).toString(); + + let currentKey = null; + let currentName = null; + let currentBasePath = null; + + const flush = () => { + if (!currentBasePath) return; + if (distroName && currentName && currentName !== distroName) return; + addIfExists(path.join(currentBasePath, 'ext4.vhdx'), currentName || currentKey || 'WSL'); + }; + + for (const rawLine of out.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + if (!line.trim()) continue; + + // Registry key header line + if (/^HKEY_/i.test(line.trim())) { + flush(); + currentKey = line.trim(); + currentName = null; + currentBasePath = null; + continue; + } + + // Value lines: + const m = line.match(/^\s*(DistributionName|BasePath)\s+REG_\w+\s+(.+)\s*$/i); + if (!m) continue; + + const key = m[1].toLowerCase(); + const val = (m[2] || '').trim(); + if (key === 'distributionname') currentName = val; + if (key === 'basepath') currentBasePath = val; + } + flush(); + } catch { /* ignore */ } + + // Fallback: common custom directories some tools use if (userProfile) { - const customPaths = [ - path.join(userProfile, 'AppData', 'Local', 'Packages'), + const customDirs = [ path.join(userProfile, '.wsl'), + path.join(userProfile, 'WSL'), ]; - for (const dir of customPaths) { + for (const dir of customDirs) { try { - if (fs.existsSync(dir) && !dir.includes('Packages')) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const vhdxPath = path.join(dir, entry.name, 'ext4.vhdx'); - if (fs.existsSync(vhdxPath)) { - const stats = fs.statSync(vhdxPath); - results.push({ path: vhdxPath, size: stats.size, folder: entry.name }); - } - } - } + if (!fs.existsSync(dir)) continue; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + addIfExists(path.join(dir, entry.name, 'ext4.vhdx'), entry.name); } } catch { /* ignore */ } } @@ -331,18 +494,25 @@ function getDriveSpace(drivePath) { */ function runWslCommand({ command, taskId, onOutput }) { return new Promise((resolve) => { - const parts = command.split(' '); - const proc = spawn(parts[0], parts.slice(1), { windowsHide: true }); + const parts = String(command || '').trim().split(/\s+/); + // Allowlist the executable: this channel is only meant for `wsl` host-side + // commands (shutdown, update, terminate, ...). Refuse anything else so a + // compromised renderer cannot spawn arbitrary host programs. + if (parts[0] !== 'wsl') { + resolve({ ok: false, output: 'Only "wsl" commands are permitted.', code: -1 }); + return; + } + const proc = spawn('wsl', parts.slice(1), { windowsHide: true }); let fullOutput = ''; proc.stdout.on('data', (data) => { - const text = data.toString(); + const text = decodeText(data); fullOutput += text; if (taskId && onOutput) onOutput({ taskId, text }); }); proc.stderr.on('data', (data) => { - const text = data.toString(); + const text = decodeText(data); fullOutput += text; if (taskId && onOutput) onOutput({ taskId, text }); }); @@ -369,9 +539,8 @@ function runWslCommand({ command, taskId, onOutput }) { */ function optimizeVhdx({ vhdxPath, taskId, onOutput }) { return new Promise((resolve) => { - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-optimize.ps1'); - const tempOut = path.join(tempDir, 'wsl-cleaner-optimize.log'); + const tempScript = uniqueTempPath('optimize', 'ps1'); + const tempOut = uniqueTempPath('optimize', 'log'); const scriptContent = [ 'try {', @@ -455,11 +624,12 @@ function scanDiskUsage({ distro, targetPath = '/', maxDepth = 3 }) { const scriptContent = [ '#!/bin/bash', - `du -xk --max-depth=${depth} ${target} 2>/dev/null | sort -rn | head -2000`, + // Quote the target path: it originates from filesystem names and could + // contain shell metacharacters; this script runs as root. + `du -xk --max-depth=${depth} ${shellQuote(target)} 2>/dev/null | sort -rn | head -2000`, ].join('\n'); - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-diskusage.sh'); + const tempScript = uniqueTempPath('diskusage', 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -532,8 +702,7 @@ function estimateTaskSizes({ distro, tasks }) { scriptLines.push(`echo "${t.taskId}=$(${t.estimateCommand})"`); } - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-estimate.sh'); + const tempScript = uniqueTempPath('estimate', 'sh'); fs.writeFileSync(tempScript, scriptLines.join('\n').replace(/\r\n/g, '\n'), 'utf8'); // Convert Windows path → WSL path @@ -630,8 +799,7 @@ function getHealthInfo(distro) { // Write script to a temp file and pass the WSL-translated path to bash. // Using bash -c doesn't work for complex scripts (Windows CreateProcess // mangles single quotes and $() subshells in arguments). - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-health.sh'); + const tempScript = uniqueTempPath('health', 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -1289,8 +1457,7 @@ function getDistroComparison(distros) { 'exit 0', ].join('\n'); - const tempDir = process.env.TEMP || os.tmpdir(); - const tempScript = path.join(tempDir, `wsl-cleaner-compare-${distro.replace(/[^a-zA-Z0-9]/g, '_')}.sh`); + const tempScript = uniqueTempPath(`compare-${distro.replace(/[^a-zA-Z0-9]/g, '_')}`, 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -1494,8 +1661,7 @@ function getStartupServices(distro) { 'exit 0', ].join('\n'); - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-startup.sh'); + const tempScript = uniqueTempPath('startup', 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -1621,8 +1787,7 @@ function setServiceState(distro, unit, enabled) { 'exit $?', ].join('\n'); - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-svc-toggle.sh'); + const tempScript = uniqueTempPath('svc-toggle', 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -1666,8 +1831,7 @@ function getServiceDetails(distro, unit) { 'exit 0', ].join('\n'); - const tempDir = process.env.TEMP || '.'; - const tempScript = path.join(tempDir, 'wsl-cleaner-svc-detail.sh'); + const tempScript = uniqueTempPath('svc-detail', 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -1878,8 +2042,7 @@ function profileShellStartup({ distro }) { 'exit 0', ].join('\n'); - const tempDir = process.env.TEMP || os.tmpdir(); - const tempScript = path.join(tempDir, 'wsl-cleaner-profile.sh'); + const tempScript = uniqueTempPath('profile', 'sh'); fs.writeFileSync(tempScript, scriptContent.replace(/\r\n/g, '\n'), 'utf8'); const wslScriptPath = tempScript.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`); @@ -1980,6 +2143,8 @@ function parseProfilerOutput(raw) { module.exports = { wslEnv, + shellQuote, + uniqueTempPath, checkWsl, detectTools, runCleanupTask, diff --git a/locales/en.json b/locales/en.json index 32d312a..7a88f29 100644 --- a/locales/en.json +++ b/locales/en.json @@ -53,6 +53,7 @@ "simple.estimateLabel": "Estimated reclaimable:", "simple.cleanCompact": "Clean & Compact", "simple.clean": "Clean", + "simple.vhdxNotFoundCompactDisabled": "VHDX not found — running Clean only (compaction disabled).", "simple.disclaimer": "This deletes temporary files & caches. Use Settings to control exactly what will be cleaned. Designed for WSL partitions used for development.", "simple.step.cleanup": "Running cleanup tasks...", "simple.step.fstrim": "Running filesystem TRIM...", diff --git a/main.js b/main.js index 19b2a8a..4d49b1a 100644 --- a/main.js +++ b/main.js @@ -39,6 +39,64 @@ let isQuitting = false; let mainWindow; +// ── Live Output file logging ───────────────────────────────────────────────── +let liveOutputLogStream = null; +let liveOutputLogPath = null; + +function getLiveOutputLogPath() { + try { + const logsDir = path.join(app.getPath('userData'), 'logs'); + if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true }); + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const stamp = + d.getFullYear() + + pad(d.getMonth() + 1) + + pad(d.getDate()) + + '-' + + pad(d.getHours()) + + pad(d.getMinutes()) + + pad(d.getSeconds()); + return path.join(logsDir, `live-output-${stamp}.log`); + } catch { + return null; + } +} + +function ensureLiveOutputLogStream() { + if (liveOutputLogStream) return; + liveOutputLogPath = getLiveOutputLogPath(); + if (!liveOutputLogPath) return; + try { + liveOutputLogStream = fs.createWriteStream(liveOutputLogPath, { flags: 'a' }); + liveOutputLogStream.write(`--- WSL Cleaner Live Output Log (${new Date().toISOString()}) ---\n`); + } catch { + liveOutputLogStream = null; + liveOutputLogPath = null; + } +} + +function appendLiveOutputToFile({ taskId, text }) { + try { + ensureLiveOutputLogStream(); + if (!liveOutputLogStream) return; + const ts = new Date().toISOString(); + const id = taskId ? String(taskId) : 'unknown'; + // Preserve original newlines; prefix each line for readability + const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n'); + for (const line of lines) { + if (line === '') continue; + liveOutputLogStream.write(`[${ts}] [${id}] ${line}\n`); + } + } catch { /* ignore */ } +} + +function emitTaskOutput(data) { + if (!data) return; + mainWindow?.webContents.send('task-output', data); + appendLiveOutputToFile(data); +} + function createWindow() { mainWindow = new BrowserWindow({ width: 1440, @@ -57,6 +115,18 @@ function createWindow() { mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); + // Defence in depth: the app only ever loads its bundled local UI. Deny any + // attempt to open new windows, and block navigation away from the local + // renderer. External links must go through the validated open-external IPC. + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (isValidExternalUrl(url)) shell.openExternal(url); + return { action: 'deny' }; + }); + + mainWindow.webContents.on('will-navigate', (event, url) => { + if (url !== mainWindow.webContents.getURL()) event.preventDefault(); + }); + // Check for updates after the window is ready mainWindow.webContents.on('did-finish-load', () => { setTimeout(() => { @@ -140,6 +210,12 @@ app.whenReady().then(() => { app.on('before-quit', () => { isQuitting = true; + try { + if (liveOutputLogStream) { + liveOutputLogStream.end(`--- End (${new Date().toISOString()}) ---\n`); + liveOutputLogStream = null; + } + } catch { /* ignore */ } }); app.on('window-all-closed', () => { @@ -194,7 +270,7 @@ ipcMain.handle('detect-tools', async (_event, distro) => wslOps.detectTools(dist let cleanupQueue = Promise.resolve(); ipcMain.handle('run-cleanup', async (event, opts) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); // Queue each task so they run strictly one at a time const result = new Promise((resolve) => { cleanupQueue = cleanupQueue.then(() => @@ -206,7 +282,7 @@ ipcMain.handle('run-cleanup', async (event, opts) => { // ── Find VHDX files ────────────────────────────────────────────────────────── -ipcMain.handle('find-vhdx', async (_event, _distro) => wslOps.findVhdx()); +ipcMain.handle('find-vhdx', async (_event, distro) => wslOps.findVhdx(distro)); // ── Get file size ──────────────────────────────────────────────────────────── @@ -219,14 +295,14 @@ ipcMain.handle('get-available-space', async (_event, distro) => wslOps.getAvaila // ── Run Windows-side WSL commands (shutdown, update, etc.) ─────────────────── ipcMain.handle('run-wsl-command', async (event, { command, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.runWslCommand({ command, taskId, onOutput }); }); // ── Optimize VHDX via elevated PowerShell ──────────────────────────────────── ipcMain.handle('optimize-vhdx', async (_event, { vhdxPath, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.optimizeVhdx({ vhdxPath, taskId, onOutput }); }); @@ -326,22 +402,22 @@ ipcMain.handle('save-task-preferences', (_event, prefs) => { // ── Distro management ───────────────────────────────────────────────────────── ipcMain.handle('export-distro', async (event, { distro, targetPath, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.exportDistro({ distro, targetPath, taskId, onOutput }); }); ipcMain.handle('import-distro', async (event, { name, installLocation, tarPath, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.importDistro({ name, installLocation, tarPath, taskId, onOutput }); }); ipcMain.handle('clone-distro', async (event, { distro, newName, installLocation, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.cloneDistro({ distro, newName, installLocation, taskId, onOutput }); }); ipcMain.handle('restart-distro', async (event, { distro, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.restartDistro({ distro, taskId, onOutput }); }); @@ -360,12 +436,12 @@ ipcMain.handle('get-drive-space', async (_event, drivePath) => { }); ipcMain.handle('unregister-distro', async (event, { distro, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); return wslOps.unregisterDistro({ distro, taskId, onOutput }); }); ipcMain.handle('migrate-distro', async (event, { distro, destinationPath, defaultUser, keepBackup, taskId }) => { - const onOutput = (data) => mainWindow?.webContents.send('task-output', data); + const onOutput = (data) => emitTaskOutput(data); const onStep = (data) => mainWindow?.webContents.send('migrate-step', data); return wslOps.migrateDistro({ distro, destinationPath, defaultUser, keepBackup, taskId, onOutput, onStep }); }); diff --git a/package-lock.json b/package-lock.json index 0125977..6795e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ { "name": "wsl-cleaner", - "version": "1.1.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wsl-cleaner", - "version": "1.1.0", + "version": "1.8.0", "license": "MIT", "dependencies": { - "chart.js": "^4.5.1", - "dotenv": "^17.3.1", "electron-log": "^5.4.3", - "electron-updater": "^6.7.3", - "openai": "^6.22.0" + "electron-updater": "^6.7.3" }, "bin": { "wsl-cleaner": "cli.js" }, "devDependencies": { + "chart.js": "^4.5.1", "conventional-changelog-cli": "^5.0.0", + "dotenv": "^17.3.1", "electron": "^40.4.1", "electron-builder": "^26.0.0", + "openai": "^6.22.0", "vitest": "^4.0.18" } }, @@ -976,7 +976,8 @@ "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "dev": true }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", @@ -2518,6 +2519,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dev": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3243,6 +3245,7 @@ "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, "engines": { "node": ">=12" }, @@ -5168,6 +5171,7 @@ "version": "6.22.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.22.0.tgz", "integrity": "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==", + "dev": true, "bin": { "openai": "bin/cli" }, diff --git a/renderer/app.js b/renderer/app.js index 3302c83..070e167 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1131,7 +1131,7 @@ const btnSimpleGoLabel = $('#btn-simple-go-label'); btnSimpleGo.addEventListener('click', async () => { if (state.isRunning) return; - if (state.vhdxFiles.length === 0) return; + const hasVhdx = state.vhdxFiles.length > 0; // Check for aggressive tasks and prompt confirmation const aggressiveTasks = getEnabledAggressiveTasks(); @@ -1140,7 +1140,11 @@ btnSimpleGo.addEventListener('click', async () => { if (!confirmed) return; } - const doCompact = state.compactEnabled; + // If we can't locate a VHDX, we can still run cleanup + TRIM, but must skip compaction. + const doCompact = state.compactEnabled && hasVhdx; + if (state.compactEnabled && !hasVhdx) { + cfgShowToast(t('simple.vhdxNotFoundCompactDisabled'), 'error'); + } state.isRunning = true; btnSimpleGo.disabled = true; @@ -1175,11 +1179,13 @@ btnSimpleGo.addEventListener('click', async () => { const failedTasks = []; const taskSavingsMap = {}; // { taskId: { name, spaceSaved } } - // Measure total VHDX size before + // Measure total VHDX size before (only if we found VHDX files) let totalBefore = 0; - for (const vf of state.vhdxFiles) { - const res = await window.wslCleaner.getFileSize(vf.path); - totalBefore += res.ok ? res.size : 0; + if (hasVhdx) { + for (const vf of state.vhdxFiles) { + const res = await window.wslCleaner.getFileSize(vf.path); + totalBefore += res.ok ? res.size : 0; + } } // Step 1: Run cleanup tasks on each distro (respects task toggles from Settings) @@ -1332,19 +1338,21 @@ btnSimpleGo.addEventListener('click', async () => { if (simpleProgressFill) simpleProgressFill.style.width = '100%'; if (progressHeader) progressHeader.querySelector('.orbital-spinner')?.classList.add('hidden'); - // Measure total VHDX size after + // Measure total VHDX size after (only if we found VHDX files) let totalAfter = 0; - for (const vf of state.vhdxFiles) { - const res = await window.wslCleaner.getFileSize(vf.path); - totalAfter += res.ok ? res.size : 0; + if (hasVhdx) { + for (const vf of state.vhdxFiles) { + const res = await window.wslCleaner.getFileSize(vf.path); + totalAfter += res.ok ? res.size : 0; + } } const saved = totalBefore - totalAfter; const durationMs = Date.now() - simpleStart; // Show results - simpleSizeBefore.textContent = formatBytes(totalBefore); - simpleSizeAfter.textContent = formatBytes(totalAfter); + simpleSizeBefore.textContent = hasVhdx ? formatBytes(totalBefore) : '—'; + simpleSizeAfter.textContent = hasVhdx ? formatBytes(totalAfter) : '—'; const compactHint = $('#result-compact-hint'); if (!doCompact) { simpleSpaceSaved.textContent = '—'; @@ -1451,12 +1459,12 @@ $('#result-fail-box').addEventListener('click', () => { failLogList.innerHTML = tasks.map(f => { const output = f.output - ? `
${f.output.replace(/`
+      ? `
${escapeHtml(f.output)}
` : ''; - const code = f.code != null ? `Exit code ${f.code}` : ''; + const code = f.code != null ? `Exit code ${escapeHtml(String(f.code))}` : ''; return `
-
${f.name.replace(/ -
${f.distro.replace(/ +
${escapeHtml(f.name)}
+
${escapeHtml(f.distro)} ${code}
${output}
`; }).join(''); diff --git a/renderer/styles.css b/renderer/styles.css index 4c63602..60307d2 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1005,6 +1005,16 @@ a:hover { background: white; } +/* Settings toggle rows (e.g., Disk Compaction) */ +.stale-toggle-row { + display: flex; + align-items: center; + gap: 12px; +} +.stale-toggle-label { + line-height: 22px; /* match toggle height */ +} + .task-info { flex: 1; min-width: 0; @@ -1139,7 +1149,8 @@ a:hover { } .vhdx-row { - display: flex; + display: grid; + grid-template-columns: 120px 1fr; align-items: center; gap: 12px; } @@ -1147,12 +1158,17 @@ a:hover { .vhdx-row .label { color: var(--text-secondary); font-size: 13px; - min-width: 100px; + white-space: nowrap; } .vhdx-row .value { font-size: 13px; color: var(--text-primary); + min-width: 0; + line-height: 1.4; + text-align: right; + overflow-wrap: anywhere; + word-break: break-word; } .mono { diff --git a/renderer/tasks.js b/renderer/tasks.js index 4308f6e..fb2fdb1 100644 --- a/renderer/tasks.js +++ b/renderer/tasks.js @@ -104,7 +104,8 @@ const TASKS = [ id: 'snap-old-revisions', name: 'Remove Old Snap Revisions', desc: 'Removes disabled snap revisions sitting in /var/lib/snapd/snaps. Can reclaim several GB.', - command: "snap list --all 2>/dev/null | awk '/disabled/{print $1, $3}' | while read snapname revision; do echo \"Removing $snapname revision $revision\"; snap remove \"$snapname\" --revision=\"$revision\" 2>/dev/null; done; echo \"Old snap revisions removed\"", + // Escape $ so bash doesn't expand awk fields before awk runs. + command: "snap list --all 2>/dev/null | awk '/disabled/{print \\$1, \\$3}' | while read snapname revision; do echo \"Removing $snapname revision $revision\"; snap remove \"$snapname\" --revision=\"$revision\" 2>/dev/null; done; echo \"Old snap revisions removed\"", asRoot: true, requires: 'snap', category: 'pkg-managers', @@ -124,7 +125,8 @@ const TASKS = [ id: 'vscode-old-bins', name: 'Clean Old VS Code / Cursor / Windsurf Server Binaries', desc: 'Removes old server binaries from ~/.vscode-server/bin, ~/.cursor-server/bin, and ~/.windsurf-server/bin, keeping only the latest version. Each old version is ~200 MB.', - command: 'for base in ~/.vscode-server/bin ~/.cursor-server/bin ~/.windsurf-server/bin; do [ -d "$base" ] || continue; latest=$(ls -td "$base"/*/ 2>/dev/null | head -1); [ -z "$latest" ] && continue; for d in "$base"/*/; do [ "$d" = "$latest" ] && continue; echo "Removing old binary: $d"; rm -rf "$d"; done; done; echo "Old server binaries cleaned"', + // Avoid shell-specific globbing errors (e.g. zsh "no matches found") by enumerating directories safely. + command: 'setopt nonomatch 2>/dev/null || true; set +o nomatch 2>/dev/null || true; for base in ~/.vscode-server/bin ~/.cursor-server/bin ~/.windsurf-server/bin; do [ -d "$base" ] || continue; latest=$(find "$base" -mindepth 1 -maxdepth 1 -type d -exec stat -c "%Y %n" {} + 2>/dev/null | sort -nr | head -1 | awk \'{ $1=""; sub(/^ /,""); print }\'); [ -z "$latest" ] && continue; find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r d; do [ "$d" = "$latest" ] && continue; echo "Removing old binary: $d"; rm -rf "$d"; done; done; echo "Old server binaries cleaned"', asRoot: false, requires: null, category: 'user-editor', @@ -252,7 +254,7 @@ const TASKS = [ id: 'docker-prune', name: 'Clean Docker Dangling Artifacts', desc: 'Removes dangling (untagged) images, unused networks, and stale build cache. All named/tagged images, containers, and volumes are preserved.', - command: 'docker image prune -f 2>/dev/null && docker network prune -f 2>/dev/null && docker builder prune -f 2>/dev/null && echo "Docker dangling artifacts cleaned"', + command: 'docker info >/dev/null 2>&1 || { echo "Docker CLI found but daemon is not running — skipping Docker cleanup"; exit 0; }; docker image prune -f 2>/dev/null && docker network prune -f 2>/dev/null && docker builder prune -f 2>/dev/null && echo "Docker dangling artifacts cleaned"', asRoot: false, requires: 'docker', category: 'containers', diff --git a/tests/security.test.js b/tests/security.test.js new file mode 100644 index 0000000..607da31 --- /dev/null +++ b/tests/security.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +// wsl-ops destructures child_process bindings at load time, so we patch the +// cached module *before* requiring it. (vi.mock does not intercept CommonJS +// `require`, so we record calls by hand.) Each vitest file has an isolated +// module registry, so this patch does not leak to other suites. +const cp = require('child_process'); + +const calls = { execFileSync: [], execSync: [], spawn: [] }; + +cp.execFileSync = (...args) => { calls.execFileSync.push(args); return ''; }; +cp.execSync = (...args) => { calls.execSync.push(args); return ''; }; +cp.spawn = (...args) => { + calls.spawn.push(args); + // A child stub that never emits 'close' — pending promises are abandoned. + return { stdout: { on() {} }, stderr: { on() {} }, on() {} }; +}; + +const wslOps = require('../lib/wsl-ops'); + +beforeEach(() => { + calls.execFileSync.length = 0; + calls.execSync.length = 0; + calls.spawn.length = 0; +}); + +// ── #1 Command injection via distro name (detectTools) ──────────────────────── + +describe('detectTools — distro name is never shell-interpolated', () => { + const EVIL = 'Ubuntu" & calc & "'; + + it('uses execFileSync with an args array, not execSync with a shell string', () => { + wslOps.detectTools(EVIL); + expect(calls.execSync).toHaveLength(0); + expect(calls.execFileSync.length).toBeGreaterThan(0); + }); + + it('passes the distro name as a single discrete argument', () => { + wslOps.detectTools(EVIL); + const [file, args] = calls.execFileSync[0]; + expect(file).toBe('wsl'); + expect(args[0]).toBe('-d'); + // The raw distro name occupies exactly one argv slot — its metacharacters + // can never reach a shell parser. + expect(args[1]).toBe(EVIL); + expect(args).toContain('bash'); + expect(args.filter(a => a === EVIL)).toHaveLength(1); + // Nothing concatenated the name into a larger shell command string. + expect(args.some(a => a !== EVIL && a.includes('calc'))).toBe(false); + }); +}); + +// ── #2 Path injection in disk scan (shellQuote) ─────────────────────────────── + +describe('shellQuote', () => { + it('wraps a plain value in single quotes', () => { + expect(wslOps.shellQuote('/var/log')).toBe(`'/var/log'`); + }); + + it('neutralises an embedded single-quote break-out attempt', () => { + // A directory literally named to escape quoting and run `rm -rf /`. + const evil = `/tmp/'; rm -rf / #`; + const quoted = wslOps.shellQuote(evil); + expect(quoted.startsWith(`'`)).toBe(true); + expect(quoted.endsWith(`'`)).toBe(true); + expect(quoted).toBe(`'/tmp/'\\''; rm -rf / #'`); + // Embedded in a command, no bare unquoted `; rm` escapes the quotes. + const cmd = `du ${quoted}`; + expect(cmd).not.toMatch(/du '[^']*'; rm/); + }); + + it('handles backticks and $() without leaving them unquoted', () => { + expect(wslOps.shellQuote('$(reboot)`id`')).toBe(`'$(reboot)\`id\`'`); + }); +}); + +// ── #3 Predictable temp-file names (uniqueTempPath) ─────────────────────────── + +describe('uniqueTempPath', () => { + it('embeds the label and extension', () => { + const p = wslOps.uniqueTempPath('health', 'sh'); + expect(p).toMatch(/wsl-cleaner-health-[0-9a-f]+\.sh$/); + }); + + it('produces a different path on every call', () => { + expect(wslOps.uniqueTempPath('diskusage', 'sh')) + .not.toBe(wslOps.uniqueTempPath('diskusage', 'sh')); + }); +}); + +// ── #5 runWslCommand executable allowlist ───────────────────────────────────── + +describe('runWslCommand — only "wsl" is permitted', () => { + it('rejects a non-wsl executable without spawning it', async () => { + const res = await wslOps.runWslCommand({ command: 'calc.exe' }); + expect(res.ok).toBe(false); + expect(res.code).toBe(-1); + expect(calls.spawn).toHaveLength(0); + }); + + it('rejects a chained command that starts with another program', async () => { + const res = await wslOps.runWslCommand({ command: 'notepad && wsl --shutdown' }); + expect(res.ok).toBe(false); + expect(calls.spawn).toHaveLength(0); + }); + + it('rejects empty / missing command', async () => { + expect((await wslOps.runWslCommand({ command: '' })).ok).toBe(false); + expect((await wslOps.runWslCommand({})).ok).toBe(false); + expect(calls.spawn).toHaveLength(0); + }); + + it('spawns wsl (only) for a valid wsl command', () => { + // Promise stays pending (stub never closes); assert the synchronous spawn. + wslOps.runWslCommand({ command: 'wsl --shutdown' }); + expect(calls.spawn).toHaveLength(1); + const [file, args] = calls.spawn[0]; + expect(file).toBe('wsl'); + expect(args).toEqual(['--shutdown']); + }); +}); From a6d0581b2e78c79148537416744135e97259df36 Mon Sep 17 00:00:00 2001 From: trgv Date: Fri, 26 Jun 2026 14:05:29 +0300 Subject: [PATCH 2/3] feat(cleanup): sweep orphaned temp scripts at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helper scripts are unlinked when their process closes, but an orphaned WSL process can leave one behind; with random names these would slowly accumulate. Adds sweepTempScripts() — removes wsl-cleaner-*.{sh,ps1,log} older than 1h from the temp dir — and calls it once on app startup. Adds 4 regression tests (age threshold, ext filtering, foreign-file safety, missing-dir tolerance). Full suite: 195 passing. --- lib/wsl-ops.js | 31 +++++++++++++++++++++++++ main.js | 7 ++++++ tests/security.test.js | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/lib/wsl-ops.js b/lib/wsl-ops.js index 91069ad..2fbe6dc 100644 --- a/lib/wsl-ops.js +++ b/lib/wsl-ops.js @@ -25,6 +25,36 @@ function uniqueTempPath(label, ext) { return path.join(tempDir, `wsl-cleaner-${label}-${rand}.${ext}`); } +/** + * Remove stale helper scripts left in the temp directory. Scripts are normally + * unlinked when their process closes, but an orphaned/zombie WSL process can + * leave one behind; with random names (see uniqueTempPath) these would + * otherwise accumulate. Called once at startup. + * @param {Object} [opts] + * @param {string} [opts.dir] Directory to sweep (defaults to temp dir) + * @param {number} [opts.maxAgeMs] Only remove files older than this (default 1h) + * @returns {number} count of files removed + */ +function sweepTempScripts({ dir, maxAgeMs = 60 * 60 * 1000 } = {}) { + const tempDir = dir || process.env.TEMP || os.tmpdir(); + let removed = 0; + try { + const now = Date.now(); + for (const name of fs.readdirSync(tempDir)) { + if (!/^wsl-cleaner-.*\.(sh|ps1|log)$/i.test(name)) continue; + const full = path.join(tempDir, name); + try { + const stat = fs.statSync(full); + if (stat.isFile() && now - stat.mtimeMs > maxAgeMs) { + fs.unlinkSync(full); + removed++; + } + } catch { /* ignore individual files */ } + } + } catch { /* ignore — temp dir unreadable */ } + return removed; +} + // Suppress "bogus screen size" warnings from WSL and prevent BASH_ENV from // sourcing init scripts that may hijack the bash process (e.g. systemd // namespace wrappers that exec into nsenter, swallowing script output). @@ -2145,6 +2175,7 @@ module.exports = { wslEnv, shellQuote, uniqueTempPath, + sweepTempScripts, checkWsl, detectTools, runCleanupTask, diff --git a/main.js b/main.js index 4d49b1a..469fd82 100644 --- a/main.js +++ b/main.js @@ -199,6 +199,13 @@ app.whenReady().then(() => { statsDb.init(userData); perfDb.init(userData); preferences.init(userData); + + // Remove any stale helper scripts orphaned by a previous run. + try { + const swept = wslOps.sweepTempScripts(); + if (swept > 0) log.info(`Swept ${swept} stale temp script(s).`); + } catch { /* non-fatal */ } + createWindow(); // Initialise system tray if enabled diff --git a/tests/security.test.js b/tests/security.test.js index 607da31..758214a 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -17,6 +17,9 @@ cp.spawn = (...args) => { }; const wslOps = require('../lib/wsl-ops'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); beforeEach(() => { calls.execFileSync.length = 0; @@ -119,3 +122,52 @@ describe('runWslCommand — only "wsl" is permitted', () => { expect(args).toEqual(['--shutdown']); }); }); + +// ── Startup sweep of orphaned temp scripts ──────────────────────────────────── + +describe('sweepTempScripts', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsl-cleaner-sweep-test-')); + }); + + const write = (name, ageMs) => { + const full = path.join(dir, name); + fs.writeFileSync(full, 'x'); + if (ageMs) { + const t = (Date.now() - ageMs) / 1000; + fs.utimesSync(full, t, t); + } + return full; + }; + + it('removes stale wsl-cleaner scripts older than maxAge', () => { + const old = write('wsl-cleaner-health-deadbeef.sh', 2 * 60 * 60 * 1000); // 2h old + const removed = wslOps.sweepTempScripts({ dir, maxAgeMs: 60 * 60 * 1000 }); + expect(removed).toBe(1); + expect(fs.existsSync(old)).toBe(false); + }); + + it('keeps recent scripts and unrelated files', () => { + const recent = write('wsl-cleaner-diskusage-cafe.sh', 0); // just created + const unrelated = write('some-other-file.sh', 2 * 60 * 60 * 1000); // old but not ours + const removed = wslOps.sweepTempScripts({ dir, maxAgeMs: 60 * 60 * 1000 }); + expect(removed).toBe(0); + expect(fs.existsSync(recent)).toBe(true); + expect(fs.existsSync(unrelated)).toBe(true); + }); + + it('sweeps .ps1 and .log artifacts too, but not other extensions', () => { + write('wsl-cleaner-optimize-1.ps1', 2 * 60 * 60 * 1000); + write('wsl-cleaner-optimize-1.log', 2 * 60 * 60 * 1000); + const keep = write('wsl-cleaner-notes-1.txt', 2 * 60 * 60 * 1000); + const removed = wslOps.sweepTempScripts({ dir, maxAgeMs: 60 * 60 * 1000 }); + expect(removed).toBe(2); + expect(fs.existsSync(keep)).toBe(true); + }); + + it('returns 0 for a non-existent directory without throwing', () => { + expect(wslOps.sweepTempScripts({ dir: path.join(dir, 'nope') })).toBe(0); + }); +}); From c725b6ec4493b32355d6d7e0e04d4f6f9d049123 Mon Sep 17 00:00:00 2001 From: trgv Date: Sat, 4 Jul 2026 00:17:34 +0300 Subject: [PATCH 3/3] feat: WSL 2.9.3 support, wslc cleanup, and .wslconfig experimental sections (v1.9.0) Add WSL Containers (wslc) prune task, Health dashboard card, and WSL version detection with UTF-16/locale fallback. Fix .wslconfig editor to write experimental keys under [experimental], add Consomme networking mode, and correct wslc-prune command flags. Includes backward-compat tests and vscode-old-bins/git-gc fixes. 225 tests passing. --- AGENTS.md | 3 + CHANGELOG.md | 14 +++ README.md | 9 +- cli.js | 26 +++-- docs/MR-wsl-2.9.3-support.md | 174 ++++++++++++++++++++++++++++++++ lib/wsl-ops.js | 188 +++++++++++++++++++++++++++++++++- locales/en.json | 14 ++- main.js | 12 ++- package.json | 2 +- preload.js | 2 + renderer/app.js | 167 ++++++++++++++++++++++--------- renderer/index.html | 36 +++++-- renderer/styles.css | 12 +++ renderer/tasks.js | 16 ++- tests/security.test.js | 30 ++++++ tests/tasks.test.js | 36 ++++++- tests/wsl-compat.test.js | 189 +++++++++++++++++++++++++++++++++++ tests/wsl-config.test.js | 70 +++++++++++++ tests/wsl-host.test.js | 78 +++++++++++++++ tests/wsl-ops-distro.test.js | 3 +- 20 files changed, 997 insertions(+), 84 deletions(-) create mode 100644 docs/MR-wsl-2.9.3-support.md create mode 100644 tests/wsl-compat.test.js create mode 100644 tests/wsl-config.test.js create mode 100644 tests/wsl-host.test.js diff --git a/AGENTS.md b/AGENTS.md index c559055..1d8f2de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,9 @@ Tests ├── tests/cli.test.js # Tests for CLI parseArgs, stripHtml, formatBytes ├── tests/i18n.test.js # Tests for renderer/i18n.js (t, tp, tError) ├── tests/wsl-ops-distro.test.js # Tests for distro management exports and signatures + ├── tests/wsl-compat.test.js # Backward compat: WSL without version/wslc + ├── tests/wsl-config.test.js # .wslconfig INI section handling + ├── tests/wsl-host.test.js # WSL version parsing and wslc JSON └── tests/migrate.test.js # Tests for distro migration functions ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index b079abe..9d73918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# [1.9.0](https://github.com/dbfx/wsl-cleaner/compare/v1.8.0...v1.9.0) (2026-07-03) + + +### Features + +* WSL 2.9.3 support: WSL Containers (`wslc`) prune task, health dashboard card, host tool detection +* Config Editor: write experimental keys to `[experimental]` section; add Consomme networking mode +* Show installed WSL version in status bar and Health dashboard + +### Fixes + +* `vscode-old-bins` cleanup command (no awk quoting errors through `bash -lc`) +* `git-gc` skip empty repository paths in log output + # [1.8.0](https://github.com/dbfx/wsl-cleaner/compare/v1.7.1...v1.8.0) (2026-02-24) diff --git a/README.md b/README.md index 54554b0..89d9b27 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Full control over every cleanup task. Each task has a toggle, a description expl - Clean Android SDK & Gradle Build Cache - Clean Python Bytecode -- `__pycache__` directories - Clean Docker Dangling Artifacts -- removes only dangling images, unused networks, and stale build cache (all named images, containers, and volumes are preserved) +- Clean WSL Containers Dangling Artifacts -- prunes unused WSLC images, containers, and volumes via native `wslc` (WSL 2.9+; no Docker Desktop required) - Clean Terraform Plugin Cache - Clean Minikube Cache - Compact Git Repositories -- finds all repos under `/home` and runs `git reflog expire --expire=now --all` + `git gc --prune=now --aggressive` @@ -128,8 +129,8 @@ Real-time system health dashboard for your WSL distributions. Auto-refreshes eve - **Disk & I/O** -- filesystem usage bar, I/O pressure (some/full percentages) - **Networking** -- interface RX/TX stats table, listening ports table, DNS resolution status (green/red indicator with server address) - **Processes** -- top 20 by CPU, zombie process detection with count and PID table -- **Services** -- Docker container counts (running/stopped), systemd state with failed unit listing (filters out known-harmless WSL failures) -- **System info** -- installed package count, GPU/CUDA availability, WSL interop status +- **Services** -- Docker and WSL Containers (`wslc`) counts (running/stopped), systemd state with failed unit listing (filters out known-harmless WSL failures) +- **System info** -- installed WSL version, installed package count, GPU/CUDA availability, WSL interop status - **WSL config** -- reads `.wslconfig` memory/swap limits and displays alongside actual usage ### Distros @@ -173,8 +174,8 @@ GUI editor for `.wslconfig` (global) and per-distro `wsl.conf` files with valida **`.wslconfig` tab** -- global WSL 2 settings applied to all distros: - Memory limit, processor count, swap size, swap file path -- Networking mode (NAT / Mirrored), localhost forwarding, DNS tunneling, DNS proxy, auto proxy, firewall -- Auto memory reclaim (disabled / gradual / drop cache), sparse VHD, page reporting +- Networking mode (NAT / Mirrored / Consomme), localhost forwarding, DNS tunneling, DNS proxy, auto proxy, firewall +- Experimental: auto memory reclaim (disabled / gradual / drop cache), sparse VHD - Nested virtualization, VM idle timeout, GUI applications (WSLg), debug console, kernel command line **`wsl.conf` tab** -- per-distro settings with distro selector: diff --git a/cli.js b/cli.js index 9178e44..3bd8023 100644 --- a/cli.js +++ b/cli.js @@ -218,7 +218,7 @@ async function actionClean(opts) { const distro = resolveDistro(opts); if (!opts.json) info(`${c.bold}Detecting tools in ${distro}...${c.reset}`); - const tools = wslOps.detectTools(distro); + const tools = { ...wslOps.detectHostTools(), ...wslOps.detectTools(distro) }; if (opts.verbose && !opts.json) { const available = Object.entries(tools).filter(([, v]) => v).map(([k]) => k); @@ -234,6 +234,10 @@ async function actionClean(opts) { return true; }); + const hostTasks = tasksToRun.filter(t => t.host); + const distroTasks = tasksToRun.filter(t => !t.host); + tasksToRun = [...hostTasks, ...distroTasks]; + // Validate --tasks IDs if (opts.tasks) { const allIds = new Set(TASKS.map(t => t.id)); @@ -291,13 +295,19 @@ async function actionClean(opts) { ? ({ text }) => process.stdout.write(`${c.dim}${text}${c.reset}`) : undefined; - const result = await wslOps.runCleanupTask({ - distro, - taskId: task.id, - command: task.command, - asRoot: task.asRoot, - onOutput, - }); + const result = task.host + ? await wslOps.runHostCleanupTask({ + taskId: task.id, + command: task.command, + onOutput, + }) + : await wslOps.runCleanupTask({ + distro, + taskId: task.id, + command: task.command, + asRoot: task.asRoot, + onOutput, + }); results.push({ id: task.id, name: label, ...result }); diff --git a/docs/MR-wsl-2.9.3-support.md b/docs/MR-wsl-2.9.3-support.md new file mode 100644 index 0000000..4994f11 --- /dev/null +++ b/docs/MR-wsl-2.9.3-support.md @@ -0,0 +1,174 @@ +# MR: WSL 2.9.3 support & host containers cleanup (v1.9.0) + +**Branch:** `feat/wsl-2.9.3-support` +**Base:** `main` (или `security-hardening`, если MR идёт туда первым) +**Версия:** `1.9.0` + +--- + +## Summary + +Добавляет поддержку **WSL 2.9.3** и **WSL Containers (`wslc`)**: очистка dangling-артефактов на хосте, карточка в Health Dashboard, отображение версии WSL. Исправляет Config Editor — experimental-ключи `.wslconfig` пишутся в `[experimental]`, а не в `[wsl2]` (устраняет предупреждения WSL об unknown keys). Включает регрессионные тесты для старых версий WSL без `wsl --version` и без `wslc`. + +Также в ветке (если ещё не в `main`): security-hardening для WSL-команд, live-output logging, VHDX через реестр Lxss, retry при `Wsl/Service/0x8007274c`, фиксы `vscode-old-bins` / `git-gc`. + +--- + +## Контекст + +[WSL 2.9.3](https://github.com/microsoft/WSL/releases/tag/2.9.3) (pre-release) добавляет: + +- **WSL Containers (WSLC)** — нативный `wslc` CLI (аналог docker prune на хосте) +- Режим сети **Consomme** (бывший VirtioProxy) +- Улучшения memory reclaim, virtiofs, стабильности shutdown/migrate + +Пользователи **без** WSL 2.9 не должны заметить регрессий: новые фичи опциональны и отключаются через детект инструментов. + +--- + +## Изменения + +### WSL Containers (`wslc`) + +| Компонент | Что сделано | +|-----------|-------------| +| `lib/wsl-ops.js` | `detectHostTools()`, `getWslHostInfo()`, `runHostCleanupTask()` с allowlist prune-команд | +| `renderer/tasks.js` | Задача `wslc-prune` (`host: true`, `requires: 'wslc'`) | +| `renderer/app.js` | Host-задачи выполняются один раз на хосте; merge host + distro tools | +| `cli.js` | Host tools + host task execution | +| Health UI | Карточка WSL Containers (running/stopped/total) | +| Status bar | `WSL 2 Ready — N distro(s) — v2.9.3.0` при наличии `wsl --version` | + +### Config Editor (`.wslconfig`) + +- `autoMemoryReclaim`, `sparseVhd` → секция **`[experimental]`** (по [MS Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config)) +- При загрузке читаются legacy-значения из `[wsl2]` +- Удалён **`pageReporting`** (нет в актуальной документации, вызывал unknown key) +- Добавлены режимы сети: **Consomme**, **VirtioProxy (legacy)** + +### Прочие фиксы задач + +- **`vscode-old-bins`** — `find -printf` + `cut` вместо `awk` (ошибка quoting через `bash -lc`) +- **`git-gc`** — пропуск пустых путей в цикле `find` + +### Версионность и документация + +- `package.json` → **1.9.0** +- `CHANGELOG.md` — секция 1.9.0 +- `README.md` — WSLC, Consomme, experimental-секция +- `locales/en.json` — новые ключи (остальные языки: `npm run translate`) + +--- + +## Обратная совместимость + +| Сценарий | Поведение | +|----------|-----------| +| Нет `wsl --version` (старый inbox WSL) | `checkWsl()` OK, `version: null`, UI без номера версии | +| Нет `wslc` | `wslc-prune` скрыта/disabled; `runHostCleanupTask` skip с `ok: true` | +| Legacy `.wslconfig` с experimental в `[wsl2]` | Читается; при Save — перенос в `[experimental]` | +| Docker без daemon | `docker-prune` по-прежнему skip (exit 0) | + +Покрыто тестами: `tests/wsl-compat.test.js`, `tests/wsl-config.test.js`, `tests/wsl-host.test.js`. + +--- + +## Тесты + +```bash +npm test +``` + +**224 теста**, в т.ч.: + +- `wsl-compat.test.js` — старый WSL без version/wslc +- `wsl-config.test.js` — секции INI +- `wsl-host.test.js` — парсинг версий 2.0.x / 2.7.x / 2.9.x +- `tasks.test.js` — регрессии команд задач +- `security.test.js` — allowlist `runHostCleanupTask` + +--- + +## Test plan (ручная проверка) + +### Уже проверено + +- [x] **Status bar**: `WSL 2 Ready — N distro(s) — v2.9.3.0` +- [x] **Health → System Info**: WSL Version отображается (не `--`) +- [x] **Health → WSL Containers**: карточка видна (0/0/0 — OK) +- [x] **Health → авто-обновление**: версия не сбрасывается в `--` через ~15 с +- [x] **Config → Experimental**: `sparseVhd` / `autoMemoryReclaim` в `[experimental]` +- [x] **Config → Networking**: Consomme / VirtioProxy сохраняется +- [x] **Legacy config**: experimental-ключи переезжают из `[wsl2]` в `[experimental]` +- [x] **`vscode-old-bins`**: OK в Simple Clean (без ошибки awk) +- [x] **`git-gc`**: OK — `Git compaction complete` в Live Output + +### Новое в 1.9.0 + +| Что | Как проверить | Статус | +|-----|----------------|--------| +| **`wslc-prune`** | Settings → включить → Clean. В Live Output — prune на хосте, без падения | ⏳ после перезапуска GUI (fix `-f` → без флага) | +| **Config → Experimental** | `sparseVhd` / `autoMemoryReclaim` → Save → `%UserProfile%\.wslconfig` → `[experimental]` | ✅ | +| **Config → Networking** | Consomme / VirtioProxy → Save | ✅ | +| **Legacy config** | Ключи из `[wsl2]` → `[experimental]` после Save | ✅ | + +### Остальное + +- [x] **Simple mode**: одна кнопка Clean завершается без ошибок (Clean & Compact ~10 мин, `WSL restarted` в логе) +- [x] **Старая WSL / без wslc**: приложение стартует, Clean работает, `wslc-prune` недоступна (покрыто автотестами) +- [x] **Config Editor**: Save + unknown keys — OK (ручная проверка) +- [x] `npm run build` → `dist/WSL Cleaner Setup 1.9.0.exe` + +--- + +## Файлы (основные) + +``` +lib/wsl-ops.js # version, host tools, wslc stats, runHostCleanupTask +main.js, preload.js # IPC +cli.js # CLI host tasks +renderer/app.js # UI, config sections, cleanup loop +renderer/tasks.js # wslc-prune, command fixes +renderer/index.html # Health wslc card, config options +locales/en.json # i18n (en only) +tests/wsl-*.test.js # новые регрессии +CHANGELOG.md, README.md, package.json +``` + +--- + +## Перед merge + +1. **Не коммитить** `.claude/settings.local.json` (локальные настройки IDE) +2. Закоммитить изменения 1.9.0 на `feat/wsl-2.9.3-support` +3. `npm run translate` — если нужны все локали в релизе +4. Убедиться, что CI зелёный (`npm test`) + +--- + +## Предлагаемый title + +``` +feat: WSL 2.9.3 support, wslc cleanup, and .wslconfig experimental sections (v1.9.0) +``` + +## Предлагаемое описание для GitHub (краткое) + +```markdown +## Summary +- Add WSL Containers (`wslc`) prune task and Health dashboard card (WSL 2.9+) +- Fix `.wslconfig` editor: write experimental keys to `[experimental]`; add Consomme networking mode +- Show WSL version in status bar and Health; graceful fallback on older WSL +- Fix `vscode-old-bins` awk quoting regression and `git-gc` empty path logs +- Bump version to 1.9.0; 224 tests including backward-compat suite + +## Test plan +- [x] Status bar and Health show WSL version; auto-refresh does not reset to `--` +- [x] Health → WSL Containers card visible (WSL 2.9.3+) +- [ ] `wslc-prune`: Advanced → Clean — host prune in log, no failure +- [ ] Config Editor: `sparseVhd` / `autoMemoryReclaim` saved under `[experimental]`; Consomme networking persists +- [ ] Legacy `[wsl2]` experimental keys migrate to `[experimental]` on Save +- [ ] `npm test` (224 passing) +- [ ] Clean & Compact on WSL 2.9.3 — `vscode-old-bins` succeeds +- [ ] App works on WSL without `wslc` (task hidden, no errors) +``` diff --git a/lib/wsl-ops.js b/lib/wsl-ops.js index 2fbe6dc..df3358a 100644 --- a/lib/wsl-ops.js +++ b/lib/wsl-ops.js @@ -114,25 +114,202 @@ function shellQuote(value) { // ── WSL detection ──────────────────────────────────────────────────────────── +/** @param {string} text */ +function parseWslVersionOutput(text) { + const version = {}; + const wslMatch = text.match(/WSL[^:\n]*:\s*(\d+\.\d+\.\d+(?:\.\d+)?)/i) + || text.match(/Версия\s+WSL\s*:\s*(\d+\.\d+\.\d+(?:\.\d+)?)/iu); + if (wslMatch) version.wsl = wslMatch[1]; + const kernelMatch = text.match(/Kernel[^:\n]*:\s*(\S+)/i) + || text.match(/ядра\s*:\s*(\S+)/iu); + if (kernelMatch) version.kernel = kernelMatch[1]; + return version; +} + +function readWslVersion() { + try { + const buf = execSync('wsl --version', { + windowsHide: true, + env: wslEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const text = typeof buf === 'string' ? buf : decodeText(buf); + const parsed = parseWslVersionOutput(text); + return parsed.wsl ? parsed : null; + } catch { + return null; + } +} + +/** @param {string} output JSON from `wslc ps -a --format json` */ +function parseWslcContainerListJson(output) { + let list; + try { + list = JSON.parse(output || '[]'); + } catch { + return null; + } + if (!Array.isArray(list)) return null; + + let running = 0; + let stopped = 0; + for (const item of list) { + const status = String( + item?.Status ?? item?.status ?? item?.State?.Status ?? item?.state?.status ?? '', + ).toLowerCase(); + if (/running|^up\b/.test(status)) running++; + else stopped++; + } + return { running, stopped, total: running + stopped }; +} + +function isWslcCliAvailable() { + try { + execFileSync('wslc', ['--version'], { + encoding: 'utf8', + timeout: 5000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return true; + } catch { + return false; + } +} + +/** + * Host-level WSL info (version + WSL Containers stats). + * @returns {{ version: Object|null, wslc: { running: number, stopped: number, total: number }|null }} + */ +function getWslHostInfo() { + const version = readWslVersion(); + if (!isWslcCliAvailable()) { + return { version, wslc: null }; + } + + try { + const output = execFileSync('wslc', ['ps', '-a', '--format', 'json'], { + encoding: 'utf8', + timeout: 10000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }).toString(); + const stats = parseWslcContainerListJson(output); + return { version, wslc: stats ?? { running: 0, stopped: 0, total: 0 } }; + } catch { + // wslc is installed but listing failed — still show the card with zeros. + return { version, wslc: { running: 0, stopped: 0, total: 0 } }; + } +} + /** * List WSL 2 distributions. - * @returns {{ ok: boolean, distros?: Array, defaultDistro?: string, error?: string }} + * @returns {{ ok: boolean, distros?: Array, defaultDistro?: string, version?: Object, error?: string }} */ function checkWsl() { try { - const output = execSync('wsl -l -v', { encoding: 'utf16le', stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); + const output = execSync('wsl -l -v', { + encoding: 'utf16le', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + env: wslEnv, + }).toString().trim(); const { distros, defaultDistro } = parseWslOutput(output); if (distros.length === 0) { return { ok: false, error: 'No WSL 2 distributions found. Please install a WSL 2 distro first.' }; } - return { ok: true, distros, defaultDistro }; + return { ok: true, distros, defaultDistro, version: readWslVersion() }; } catch (err) { return { ok: false, error: friendlyError(err.message) || 'WSL 2 is not installed or not available. Please install WSL 2 first.' }; } } +/** + * Detect Windows-host tools (not available inside a distro). + * @returns {Object} + */ +function detectHostTools() { + return { wslc: isWslcCliAvailable() }; +} + +const ALLOWED_WSLC_PRUNE_TARGETS = new Set(['image', 'container', 'volume', 'network']); + +function isAllowedWslcPruneStep(parts) { + if (parts.length < 2 || parts.length > 3) return false; + if (!ALLOWED_WSLC_PRUNE_TARGETS.has(parts[0])) return false; + if (parts[1] !== 'prune') return false; + if (parts.length === 2) return true; + // wslc uses -f/--filter (requires a value), not docker-style force; allow --all only. + return (parts[2] === '-a' || parts[2] === '--all') + && (parts[0] === 'image' || parts[0] === 'volume'); +} + +/** + * Run an allowlisted wslc maintenance command on the Windows host. + * Command uses `|` to separate steps, e.g. `image prune|container prune|volume prune`. + * @param {Object} opts + * @param {string} opts.taskId + * @param {string} opts.command + * @param {function} [opts.onOutput] + * @returns {Promise<{ ok: boolean, output: string, code: number }>} + */ +function runHostCleanupTask({ taskId, command, onOutput }) { + const steps = String(command || '').split('|').map(s => s.trim()).filter(Boolean); + if (steps.length === 0) { + return Promise.resolve({ ok: false, output: 'Empty host command.', code: -1 }); + } + + const runStep = (args) => new Promise((resolve) => { + const proc = spawn('wslc', args, { windowsHide: true }); + let output = ''; + proc.stdout.on('data', (data) => { + const text = decodeText(data); + output += text; + if (onOutput) onOutput({ taskId, text }); + }); + proc.stderr.on('data', (data) => { + const text = decodeText(data); + output += text; + if (onOutput) onOutput({ taskId, text }); + }); + proc.on('close', (code) => resolve({ ok: code === 0, output, code })); + proc.on('error', (err) => resolve({ ok: false, output: friendlyError(err.message), code: -1 })); + }); + + return (async () => { + for (const step of steps) { + const parts = step.split(/\s+/); + if (!isAllowedWslcPruneStep(parts)) { + return { ok: false, output: 'Disallowed wslc host command step.\n', code: -1 }; + } + } + + try { + execFileSync('wslc', ['--version'], { + encoding: 'utf8', + timeout: 5000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + const msg = 'wslc is not installed — skipping WSL Containers cleanup\n'; + if (onOutput) onOutput({ taskId, text: msg }); + return { ok: true, output: msg, code: 0 }; + } + + let fullOutput = ''; + for (const step of steps) { + const parts = step.split(/\s+/); + const result = await runStep(parts); + fullOutput += result.output; + if (!result.ok) return { ...result, output: fullOutput }; + } + return { ok: true, output: fullOutput, code: 0 }; + })(); +} + // ── Tool detection ─────────────────────────────────────────────────────────── const TOOL_CHECKS = [ @@ -2176,8 +2353,13 @@ module.exports = { shellQuote, uniqueTempPath, sweepTempScripts, + parseWslVersionOutput, + parseWslcContainerListJson, checkWsl, detectTools, + detectHostTools, + getWslHostInfo, + runHostCleanupTask, runCleanupTask, findVhdx, getFileSize, diff --git a/locales/en.json b/locales/en.json index 7a88f29..372cb0c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -30,6 +30,7 @@ "status.detecting": "Detecting WSL 2...", "status.ready": "WSL 2 Ready", "status.readyCount": "WSL 2 Ready — {count} distro(s) found", + "status.readyCountVersion": "WSL 2 Ready — {count} distro(s) — v{version}", "distro.none": "No distro", "distro.count": "{count} distros selected", @@ -266,6 +267,11 @@ "health.dockerTotal": "Total", "health.noDocker": "Docker not installed.", + "health.wslc": "WSL Containers", + "health.wslcRunning": "Running", + "health.wslcStopped": "Stopped", + "health.wslcTotal": "Total", + "health.systemd": "Systemd", "health.systemdState": "State", "health.systemdRunning": "All services are running normally.", @@ -279,6 +285,7 @@ "health.noSystemd": "Systemd not available.", "health.systemInfo": "System Info", + "health.wslVersion": "WSL Version", "health.packages": "Installed Packages", "health.gpu": "GPU", "health.gpuNone": "Not available", @@ -345,6 +352,8 @@ "task.android-cache.desc": "Removes ~/.android/cache, ~/.android/build-cache, and Gradle project build/ directories. Mobile devs can have 5–20 GB here.", "task.docker-prune.name": "Clean Docker Dangling Artifacts", "task.docker-prune.desc": "Removes dangling (untagged) images, unused networks, and stale build cache. All named/tagged images, containers, and volumes are preserved.", + "task.wslc-prune.name": "Clean WSL Containers Dangling Artifacts", + "task.wslc-prune.desc": "Prunes unused WSL Containers (WSLC) images, stopped containers, and volumes via native wslc (WSL 2.9+). Does not require Docker Desktop.", "task.pip-cache.name": "Clean Pip Cache Directory", "task.pip-cache.desc": "Removes downloaded pip packages from ~/.cache/pip.", "task.pnpm-cache.name": "Clean pnpm Store", @@ -619,6 +628,7 @@ "config.title": "WSL Config Editor", "config.tabWslconfig": ".wslconfig", + "config.experimentalSection": "Experimental settings ([experimental] section)", "config.tabWslconf": "wsl.conf", "config.wslconfigDesc": "Global settings that apply to all WSL 2 distros. Located at {path}.", "config.wslconfDesc": "Per-distro settings. Located at /etc/wsl.conf inside the selected distro.", @@ -657,7 +667,7 @@ "config.field.localhostForwarding": "Localhost Forwarding", "config.field.localhostForwarding.desc": "Forward ports bound to localhost in WSL to the host", "config.field.networkingMode": "Networking Mode", - "config.field.networkingMode.desc": "NAT (default) or Mirrored (shares host network interfaces)", + "config.field.networkingMode.desc": "NAT (default), Mirrored, or Consomme (routes Linux traffic through Windows; VirtioProxy alias)", "config.field.dnsTunneling": "DNS Tunneling", "config.field.dnsTunneling.desc": "Tunnel DNS requests for better VPN compatibility", "config.field.dnsProxy": "DNS Proxy", @@ -670,8 +680,6 @@ "config.field.autoMemoryReclaim.desc": "How WSL returns unused memory to the host", "config.field.sparseVhd": "Sparse VHD", "config.field.sparseVhd.desc": "Use sparse VHDX files to save disk space automatically", - "config.field.pageReporting": "Page Reporting", - "config.field.pageReporting.desc": "Report unused memory pages back to the Windows host", "config.field.nestedVirtualization": "Nested Virtualization", "config.field.nestedVirtualization.desc": "Enable VMs inside WSL 2 (e.g. Docker-in-Docker)", "config.field.vmIdleTimeout": "VM Idle Timeout", diff --git a/main.js b/main.js index 469fd82..86b920d 100644 --- a/main.js +++ b/main.js @@ -268,6 +268,10 @@ ipcMain.handle('open-external-url', async (_event, url) => { ipcMain.handle('check-wsl', async () => wslOps.checkWsl()); +ipcMain.handle('detect-host-tools', async () => wslOps.detectHostTools()); + +ipcMain.handle('get-wsl-host-info', async () => wslOps.getWslHostInfo()); + // ── Detect available tools inside WSL ──────────────────────────────────────── ipcMain.handle('detect-tools', async (_event, distro) => wslOps.detectTools(distro)); @@ -278,11 +282,11 @@ let cleanupQueue = Promise.resolve(); ipcMain.handle('run-cleanup', async (event, opts) => { const onOutput = (data) => emitTaskOutput(data); - // Queue each task so they run strictly one at a time + const run = opts.host + ? () => wslOps.runHostCleanupTask({ ...opts, onOutput }) + : () => wslOps.runCleanupTask({ ...opts, onOutput }); const result = new Promise((resolve) => { - cleanupQueue = cleanupQueue.then(() => - wslOps.runCleanupTask({ ...opts, onOutput }).then(resolve) - ); + cleanupQueue = cleanupQueue.then(() => run().then(resolve)); }); return result; }); diff --git a/package.json b/package.json index 0ee5d57..9b160ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wsl-cleaner", - "version": "1.8.0", + "version": "1.9.0", "description": "Clean and reclaim disk space from WSL2", "main": "main.js", "bin": { diff --git a/preload.js b/preload.js index 5803c52..f9ce068 100644 --- a/preload.js +++ b/preload.js @@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld('wslCleaner', { // WSL detection checkWsl: () => ipcRenderer.invoke('check-wsl'), detectTools: (distro) => ipcRenderer.invoke('detect-tools', distro), + detectHostTools: () => ipcRenderer.invoke('detect-host-tools'), + getWslHostInfo: () => ipcRenderer.invoke('get-wsl-host-info'), // Cleanup tasks runCleanup: (opts) => ipcRenderer.invoke('run-cleanup', opts), diff --git a/renderer/app.js b/renderer/app.js index 070e167..1b82a40 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -10,6 +10,7 @@ let state = { vhdxByDistro: {}, // { "Ubuntu": [{ path, size }], ... } tools: {}, // merged tool availability across selected distros vhdxFiles: [], // merged VHDX list across selected distros + wslHostVersion: null, // from checkWsl / getWslHostInfo (WSL 2.9+) taskEnabled: {}, taskSearch: '', // search filter for task cards @@ -494,13 +495,15 @@ async function refreshDistroData() { state.toolsByDistro = {}; state.vhdxByDistro = {}; + const hostTools = await window.wslCleaner.detectHostTools(); + for (const distro of state.selectedDistros) { state.toolsByDistro[distro] = await window.wslCleaner.detectTools(distro); state.vhdxByDistro[distro] = await window.wslCleaner.findVhdx(distro); } - // Merge tools: available if ANY selected distro has it - const mergedTools = {}; + // Merge tools: available if ANY selected distro has it (or host provides wslc) + const mergedTools = { ...hostTools }; for (const distro of state.selectedDistros) { const dt = state.toolsByDistro[distro] || {}; for (const [key, val] of Object.entries(dt)) { @@ -1196,60 +1199,80 @@ btnSimpleGo.addEventListener('click', async () => { // Pre-compute total task count across all distros for sub-progress let cleanupTaskTotal = 0; + const hostTools = state.tools || {}; + cleanupTaskTotal += TASKS.filter(task => { + if (!task.host || task.id === 'fstrim') return false; + if (!state.taskEnabled[task.id]) return false; + return !task.requires || hostTools[task.requires]; + }).length; for (const distro of state.selectedDistros) { const distroTools = state.toolsByDistro[distro] || {}; cleanupTaskTotal += TASKS.filter(task => { - if (task.id === 'fstrim') return false; + if (task.host || task.id === 'fstrim') return false; if (!state.taskEnabled[task.id]) return false; return !task.requires || distroTools[task.requires]; }).length; } - for (const distro of state.selectedDistros) { - const distroTools = state.toolsByDistro[distro] || {}; - const availableTasks = TASKS.filter(task => { - if (task.id === 'fstrim') return false; // fstrim runs separately in step 3 - if (!state.taskEnabled[task.id]) return false; - const available = !task.requires || distroTools[task.requires]; - return available; + const runOneCleanupTask = async (task, distro) => { + cleanupTaskIndex++; + const taskName = t('task.' + task.id + '.name') || task.name || task.id; + if (cleanupStepSub) { + cleanupStepSub.textContent = taskName + ' (' + cleanupTaskIndex + ' / ' + cleanupTaskTotal + ')'; + } + simpleTotalRun++; + const beforeSpace = task.host ? null : await window.wslCleaner.getAvailableSpace(distro); + const result = await window.wslCleaner.runCleanup({ + distro, + taskId: task.id, + command: task.command, + asRoot: task.asRoot, + host: !!task.host, }); - for (const task of availableTasks) { - cleanupTaskIndex++; - const taskName = t('task.' + task.id + '.name') || task.name || task.id; - if (cleanupStepSub) { - cleanupStepSub.textContent = taskName + ' (' + cleanupTaskIndex + ' / ' + cleanupTaskTotal + ')'; - } - simpleTotalRun++; - const beforeSpace = await window.wslCleaner.getAvailableSpace(distro); - const result = await window.wslCleaner.runCleanup({ - distro, - taskId: task.id, - command: task.command, - asRoot: task.asRoot, - }); - if (result.ok) { - simpleTotalOk++; + if (result.ok) { + simpleTotalOk++; + if (!task.host && beforeSpace?.ok) { const afterSpace = await window.wslCleaner.getAvailableSpace(distro); - if (beforeSpace.ok && afterSpace.ok) { + if (afterSpace.ok) { const delta = afterSpace.bytes - beforeSpace.bytes; if (delta > 0) { if (!taskSavingsMap[task.id]) { taskSavingsMap[task.id] = { - name: t('task.' + task.id + '.name') || task.name || task.id, + name: taskName, spaceSaved: 0, }; } taskSavingsMap[task.id].spaceSaved += delta; } } - } else { - simpleTotalFail++; - cleanupOk = false; - const taskName = t('task.' + task.id + '.name') || task.name || task.id; - const detail = (result.output || '').trim(); - cleanupErrors.push('[' + taskName + '] ' + (detail || 'Exit code ' + result.code)); - failedTasks.push({ name: taskName, distro, output: detail, code: result.code }); } + } else { + simpleTotalFail++; + cleanupOk = false; + const detail = (result.output || '').trim(); + cleanupErrors.push('[' + taskName + '] ' + (detail || 'Exit code ' + result.code)); + failedTasks.push({ name: taskName, distro: task.host ? 'host' : distro, output: detail, code: result.code }); + } + }; + + const hostTasks = TASKS.filter(task => { + if (!task.host || task.id === 'fstrim') return false; + if (!state.taskEnabled[task.id]) return false; + return !task.requires || hostTools[task.requires]; + }); + for (const task of hostTasks) { + await runOneCleanupTask(task, state.selectedDistros[0]); + } + + for (const distro of state.selectedDistros) { + const distroTools = state.toolsByDistro[distro] || {}; + const availableTasks = TASKS.filter(task => { + if (task.host || task.id === 'fstrim') return false; + if (!state.taskEnabled[task.id]) return false; + return !task.requires || distroTools[task.requires]; + }); + for (const task of availableTasks) { + await runOneCleanupTask(task, distro); } } if (cleanupStepSub) cleanupStepSub.textContent = ''; @@ -2286,7 +2309,15 @@ async function init() { state.distros = wslCheck.distros; state.selectedDistros = [wslCheck.defaultDistro]; - statusText.textContent = t('status.readyCount', { count: wslCheck.distros.length }); + state.wslHostVersion = wslCheck.version?.wsl || null; + if (wslCheck.version?.wsl) { + statusText.textContent = t('status.readyCountVersion', { + count: wslCheck.distros.length, + version: wslCheck.version.wsl, + }); + } else { + statusText.textContent = t('status.readyCount', { count: wslCheck.distros.length }); + } // Restore saved task preferences and settings (overlay on top of defaults) try { @@ -2626,6 +2657,10 @@ function showHealthState(which) { else if (which === 'error') healthError.classList.remove('hidden'); } +function applyHealthHostInfo(hostInfo) { + if (hostInfo?.version?.wsl) state.wslHostVersion = hostInfo.version.wsl; +} + async function renderHealthPage() { populateHealthDistros(); @@ -2638,14 +2673,18 @@ async function renderHealthPage() { showHealthState('loading'); try { - const result = await window.wslCleaner.getHealthInfo(distro); + const [result, hostInfo] = await Promise.all([ + window.wslCleaner.getHealthInfo(distro), + window.wslCleaner.getWslHostInfo(), + ]); if (!result.ok) { console.error('[Health] Backend error:', result.error); healthErrorMsg.textContent = t('health.error') + (result.error ? '\n' + result.error : ''); showHealthState('error'); return; } - populateHealthData(result.data); + applyHealthHostInfo(hostInfo); + populateHealthData(result.data, hostInfo); showHealthState('content'); } catch (err) { console.error('[Health] Exception:', err); @@ -2654,7 +2693,7 @@ async function renderHealthPage() { } } -function populateHealthData(data) { +function populateHealthData(data, hostInfo) { // Summary cards const kernelEl = document.getElementById('health-kernel'); const uptimeEl = document.getElementById('health-uptime'); @@ -2835,6 +2874,17 @@ function populateHealthData(data) { dockerCard.classList.add('hidden'); } + // ── WSL Containers (wslc) ── + const wslcCard = document.getElementById('health-wslc-card'); + if (hostInfo?.wslc) { + wslcCard.classList.remove('hidden'); + document.getElementById('health-wslc-running').textContent = hostInfo.wslc.running; + document.getElementById('health-wslc-stopped').textContent = hostInfo.wslc.stopped; + document.getElementById('health-wslc-total').textContent = hostInfo.wslc.total; + } else { + wslcCard.classList.add('hidden'); + } + // ── Systemd ── const systemdCard = document.getElementById('health-systemd-card'); if (data.systemd) { @@ -2905,6 +2955,11 @@ function populateHealthData(data) { } // ── System Info ── + const wslVersionEl = document.getElementById('health-wsl-version'); + if (wslVersionEl) { + wslVersionEl.textContent = hostInfo?.version?.wsl || state.wslHostVersion || '--'; + } + const packagesEl = document.getElementById('health-packages'); packagesEl.textContent = data.packages != null ? data.packages.toLocaleString() : '--'; @@ -2946,9 +3001,13 @@ async function refreshHealthSilent() { if (!distro) return; try { - const result = await window.wslCleaner.getHealthInfo(distro); + const [result, hostInfo] = await Promise.all([ + window.wslCleaner.getHealthInfo(distro), + window.wslCleaner.getWslHostInfo(), + ]); if (result.ok && state.currentPage === 'health') { - populateHealthData(result.data); + applyHealthHostInfo(hostInfo); + populateHealthData(result.data, hostInfo); showHealthState('content'); } } catch (err) { @@ -3821,9 +3880,8 @@ const WSL_CONFIG_FIELDS = [ { id: 'cfg-wsl2-dnsProxy', section: 'wsl2', key: 'dnsProxy', type: 'bool' }, { id: 'cfg-wsl2-autoProxy', section: 'wsl2', key: 'autoProxy', type: 'bool' }, { id: 'cfg-wsl2-firewall', section: 'wsl2', key: 'firewall', type: 'bool' }, - { id: 'cfg-wsl2-autoMemoryReclaim', section: 'wsl2', key: 'autoMemoryReclaim', type: 'select' }, - { id: 'cfg-wsl2-sparseVhd', section: 'wsl2', key: 'sparseVhd', type: 'bool' }, - { id: 'cfg-wsl2-pageReporting', section: 'wsl2', key: 'pageReporting', type: 'bool' }, + { id: 'cfg-wsl2-autoMemoryReclaim', section: 'experimental', key: 'autoMemoryReclaim', type: 'select' }, + { id: 'cfg-wsl2-sparseVhd', section: 'experimental', key: 'sparseVhd', type: 'bool' }, { id: 'cfg-wsl2-nestedVirtualization', section: 'wsl2', key: 'nestedVirtualization', type: 'bool' }, { id: 'cfg-wsl2-vmIdleTimeout', section: 'wsl2', key: 'vmIdleTimeout', type: 'number' }, { id: 'cfg-wsl2-guiApplications', section: 'wsl2', key: 'guiApplications', type: 'bool' }, @@ -3890,6 +3948,24 @@ function cfgPopulateFields(fields, data) { } } +/** Read a .wslconfig field, including legacy [wsl2] placement of experimental keys. */ +function cfgGetWslFieldValue(data, field) { + const sectionData = data?.[field.section]; + if (sectionData?.[field.key] != null && sectionData[field.key] !== '') { + return sectionData[field.key]; + } + if (field.section === 'experimental' && data?.wsl2?.[field.key] != null) { + return data.wsl2[field.key]; + } + return ''; +} + +function cfgPopulateWslConfigFields(data) { + for (const f of WSL_CONFIG_FIELDS) { + cfgSetFieldValue(f.id, cfgGetWslFieldValue(data, f)); + } +} + function cfgCollectFields(fields) { const result = {}; for (const f of fields) { @@ -3988,7 +4064,6 @@ function cfgOptimizeWslConfig() { cfgSetFieldValue('cfg-wsl2-firewall', true); cfgSetFieldValue('cfg-wsl2-autoMemoryReclaim', 'gradual'); cfgSetFieldValue('cfg-wsl2-sparseVhd', true); - cfgSetFieldValue('cfg-wsl2-pageReporting', true); cfgSetFieldValue('cfg-wsl2-nestedVirtualization', false); cfgSetFieldValue('cfg-wsl2-guiApplications', true); cfgSetFieldValue('cfg-wsl2-debugConsole', false); @@ -4038,7 +4113,7 @@ async function renderConfigPage() { try { const result = await window.wslCleaner.readWslConfig(); if (result.ok) { - cfgPopulateFields(WSL_CONFIG_FIELDS, result.data); + cfgPopulateWslConfigFields(result.data); // Show the config path in the description if (result.path) { const descEl = $('#config-wslconfig-desc'); diff --git a/renderer/index.html b/renderer/index.html index f8f79a0..e60dc22 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -742,6 +742,24 @@

Cleanup History

+ +
+
WSL Containers
+
+
+ Running + 0 +
+
+ Stopped + 0 +
+
+ Total + 0 +
+
+
@@ -781,6 +799,10 @@

Cleanup History

System Info
+
+ WSL Version + -- +
Installed Packages -- @@ -1073,6 +1095,8 @@

WSL Config E + +

@@ -1117,6 +1141,8 @@

WSL Config E

+ +
@@ -1142,16 +1168,6 @@

WSL Config E

-
-
- - Report unused memory pages -
-
- -
-
-
diff --git a/renderer/styles.css b/renderer/styles.css index 60307d2..381b96f 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -3302,6 +3302,18 @@ a:hover { min-width: 0; flex: 1; } +.config-section-label { + grid-column: 1 / -1; + margin: 16px 0 4px; + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + .config-field-label { font-size: 13px; font-weight: 500; diff --git a/renderer/tasks.js b/renderer/tasks.js index fb2fdb1..3eb9b41 100644 --- a/renderer/tasks.js +++ b/renderer/tasks.js @@ -125,8 +125,8 @@ const TASKS = [ id: 'vscode-old-bins', name: 'Clean Old VS Code / Cursor / Windsurf Server Binaries', desc: 'Removes old server binaries from ~/.vscode-server/bin, ~/.cursor-server/bin, and ~/.windsurf-server/bin, keeping only the latest version. Each old version is ~200 MB.', - // Avoid shell-specific globbing errors (e.g. zsh "no matches found") by enumerating directories safely. - command: 'setopt nonomatch 2>/dev/null || true; set +o nomatch 2>/dev/null || true; for base in ~/.vscode-server/bin ~/.cursor-server/bin ~/.windsurf-server/bin; do [ -d "$base" ] || continue; latest=$(find "$base" -mindepth 1 -maxdepth 1 -type d -exec stat -c "%Y %n" {} + 2>/dev/null | sort -nr | head -1 | awk \'{ $1=""; sub(/^ /,""); print }\'); [ -z "$latest" ] && continue; find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r d; do [ "$d" = "$latest" ] && continue; echo "Removing old binary: $d"; rm -rf "$d"; done; done; echo "Old server binaries cleaned"', + // Use find -printf (no globs, no awk) so the command survives bash -lc quoting intact. + command: 'for base in ~/.vscode-server/bin ~/.cursor-server/bin ~/.windsurf-server/bin; do [ -d "$base" ] || continue; latest=$(find "$base" -mindepth 1 -maxdepth 1 -type d -printf \'%T@ %p\\n\' 2>/dev/null | sort -rn | head -1 | cut -d\' \' -f2-); [ -z "$latest" ] && continue; find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r d; do [ "$d" = "$latest" ] && continue; echo "Removing old binary: $d"; rm -rf "$d"; done; done; echo "Old server binaries cleaned"', asRoot: false, requires: null, category: 'user-editor', @@ -260,6 +260,16 @@ const TASKS = [ category: 'containers', // No estimateCommand — docker system df output is complex and fragile to parse }, + { + id: 'wslc-prune', + name: 'Clean WSL Containers Dangling Artifacts', + desc: 'Prunes unused WSL Containers (WSLC) images, stopped containers, and volumes via native wslc (WSL 2.9+). Does not require Docker Desktop.', + command: 'image prune|container prune|volume prune', + asRoot: false, + requires: 'wslc', + host: true, + category: 'containers', + }, { id: 'pip-cache', name: 'Clean Pip Cache Directory', @@ -454,7 +464,7 @@ const TASKS = [ id: 'git-gc', name: 'Compact Git Repositories', desc: 'Finds all git repos under /home and aggressively compacts them: expires reflog entries and repacks objects with maximum compression. Branches, tags, and reachable commits are untouched. Reflog recovery history is lost.', - command: 'find /home -maxdepth 6 -type d -name .git 2>/dev/null | while IFS= read -r gitdir; do repo=$(dirname "$gitdir"); echo "Compacting: $repo"; git -C "$repo" reflog expire --expire=now --all 2>/dev/null; git -C "$repo" gc --prune=now --aggressive 2>/dev/null; done; echo "Git compaction complete"', + command: 'find /home -maxdepth 6 -type d -name .git 2>/dev/null | while IFS= read -r gitdir; do [ -n "$gitdir" ] || continue; [ -d "$gitdir" ] || continue; repo=$(dirname "$gitdir"); [ -n "$repo" ] && [ "$repo" != "." ] || continue; echo "Compacting: $repo"; git -C "$repo" reflog expire --expire=now --all 2>/dev/null; git -C "$repo" gc --prune=now --aggressive 2>/dev/null; done; echo "Git compaction complete"', asRoot: true, requires: null, category: 'containers', diff --git a/tests/security.test.js b/tests/security.test.js index 758214a..bb5f7e7 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -123,6 +123,36 @@ describe('runWslCommand — only "wsl" is permitted', () => { }); }); +// ── #6 runHostCleanupTask wslc allowlist ────────────────────────────────────── + +describe('runHostCleanupTask — only allowlisted wslc prune steps', () => { + it('rejects a non-allowlisted wslc step without spawning', async () => { + const res = await wslOps.runHostCleanupTask({ taskId: 't', command: 'run evil' }); + expect(res.ok).toBe(false); + expect(res.output).toMatch(/Disallowed/i); + expect(calls.spawn).toHaveLength(0); + }); + + it('rejects docker-style -f flag (wslc -f is --filter, not force)', async () => { + const res = await wslOps.runHostCleanupTask({ + taskId: 't', + command: 'image prune -f', + }); + expect(res.ok).toBe(false); + expect(res.output).toMatch(/Disallowed/i); + expect(calls.spawn).toHaveLength(0); + }); + + it('rejects shell metacharacters in a step', async () => { + const res = await wslOps.runHostCleanupTask({ + taskId: 't', + command: 'image prune; calc.exe', + }); + expect(res.ok).toBe(false); + expect(calls.spawn).toHaveLength(0); + }); +}); + // ── Startup sweep of orphaned temp scripts ──────────────────────────────────── describe('sweepTempScripts', () => { diff --git a/tests/tasks.test.js b/tests/tasks.test.js index eb080f8..111aa04 100644 --- a/tests/tasks.test.js +++ b/tests/tasks.test.js @@ -4,7 +4,7 @@ const { TASKS, CATEGORIES } = require('../renderer/tasks'); // Valid tool names that the app detects via `which` in WSL const VALID_TOOL_NAMES = [ 'apt', 'dnf', 'npm', 'yarn', 'pnpm', 'go', 'pip', 'pip3', - 'composer', 'snap', 'docker', 'mvn', 'gradle', 'conda', + 'composer', 'snap', 'docker', 'wslc', 'mvn', 'gradle', 'conda', 'gem', 'dotnet', 'deno', 'bun', 'dart', 'brew', 'ccache', 'bazel', 'terraform', 'minikube', 'sbt', 'conan', ]; @@ -64,6 +64,40 @@ describe('TASKS array integrity', () => { } }); + it('host field is boolean when present', () => { + for (const task of TASKS) { + if ('host' in task) { + expect(typeof task.host).toBe('boolean'); + } + } + }); + + it('has the wslc-prune host task for WSL 2.9+', () => { + const wslc = TASKS.find(t => t.id === 'wslc-prune'); + expect(wslc).toBeDefined(); + expect(wslc.host).toBe(true); + expect(wslc.requires).toBe('wslc'); + }); + + it('vscode-old-bins command avoids awk (bash -lc quoting regression)', () => { + const task = TASKS.find(t => t.id === 'vscode-old-bins'); + expect(task).toBeDefined(); + expect(task.command).not.toMatch(/\bawk\b/); + expect(task.command).toContain('find'); + }); + + it('git-gc skips empty paths before compacting', () => { + const task = TASKS.find(t => t.id === 'git-gc'); + expect(task.command).toMatch(/\[ -n "\$gitdir" \]/); + expect(task.command).toMatch(/\[ -n "\$repo" \]/); + }); + + it('docker-prune skips when daemon is not running', () => { + const task = TASKS.find(t => t.id === 'docker-prune'); + expect(task.command).toMatch(/docker info/); + expect(task.command).toMatch(/exit 0/); + }); + it('has at least one aggressive task', () => { const aggressive = TASKS.filter(t => t.aggressive); expect(aggressive.length).toBeGreaterThan(0); diff --git a/tests/wsl-compat.test.js b/tests/wsl-compat.test.js new file mode 100644 index 0000000..159cf66 --- /dev/null +++ b/tests/wsl-compat.test.js @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +// Patch child_process before wsl-ops loads. Use stable wrapper functions so +// wsl-ops keeps a working reference after we reconfigure mocks per test. +const cp = require('child_process'); + +const calls = { execFileSync: [], execSync: [], spawn: [] }; + +let execSyncImpl = () => { + throw new Error('execSync not configured for this test'); +}; +let execFileSyncImpl = () => { + throw new Error('execFileSync not configured for this test'); +}; + +cp.execSync = (...args) => { + calls.execSync.push(args); + return execSyncImpl(...args); +}; + +cp.execFileSync = (...args) => { + calls.execFileSync.push(args); + return execFileSyncImpl(...args); +}; + +cp.spawn = (...args) => { + calls.spawn.push(args); + return { stdout: { on() {} }, stderr: { on() {} }, on() {} }; +}; + +const wslOps = require('../lib/wsl-ops'); + +const WSL_LIST_OUTPUT = ' NAME STATE VERSION\n* Ubuntu Running 2\n'; + +beforeEach(() => { + calls.execFileSync.length = 0; + calls.execSync.length = 0; + calls.spawn.length = 0; + execSyncImpl = () => { + throw new Error('execSync not configured for this test'); + }; + execFileSyncImpl = () => { + throw new Error('execFileSync not configured for this test'); + }; +}); + +function mockWslListOnly() { + execSyncImpl = (cmd) => { + if (String(cmd).includes('-l -v')) return WSL_LIST_OUTPUT; + if (String(cmd).includes('--version')) { + throw new Error('Invalid command line option: --version'); + } + throw new Error(`unexpected execSync: ${cmd}`); + }; +} + +// ── Pre-WSL 2.9: no `wsl --version` ───────────────────────────────────────── + +describe('checkWsl — older WSL without wsl --version', () => { + it('still lists distros when --version is unsupported', () => { + mockWslListOnly(); + const result = wslOps.checkWsl(); + expect(result.ok).toBe(true); + expect(result.distros).toHaveLength(1); + expect(result.defaultDistro).toBe('Ubuntu'); + expect(result.version).toBeNull(); + }); +}); + +// ── Pre-WSL 2.9: no wslc CLI ───────────────────────────────────────────────── + +describe('detectHostTools — WSL without wslc', () => { + it('returns wslc: false when wslc is not on PATH', () => { + execFileSyncImpl = (...args) => { + if (args[0] === 'wslc') throw new Error('not recognized'); + return ''; + }; + expect(wslOps.detectHostTools()).toEqual({ wslc: false }); + }); +}); + +describe('getWslHostInfo — WSL without wslc', () => { + it('returns version when available but wslc stats are null', () => { + execSyncImpl = (cmd) => { + if (String(cmd).includes('--version')) { + return 'WSL version: 2.2.4.0\nKernel version: 5.15.153.1-2\n'; + } + throw new Error(`unexpected execSync: ${cmd}`); + }; + execFileSyncImpl = (...args) => { + if (args[0] === 'wslc') throw new Error('not found'); + return ''; + }; + expect(wslOps.getWslHostInfo()).toEqual({ + version: { wsl: '2.2.4.0', kernel: '5.15.153.1-2' }, + wslc: null, + }); + }); + + it('returns wslc stats from JSON list when wslc is available', () => { + execSyncImpl = (cmd) => { + if (String(cmd).includes('--version')) { + return 'WSL version: 2.9.3.0\n'; + } + throw new Error(`unexpected execSync: ${cmd}`); + }; + execFileSyncImpl = (...args) => { + if (args[0] === 'wslc' && args[1]?.[0] === '--version') return 'wslc 2.9.3.0\n'; + if (args[0] === 'wslc' && args[1]?.includes('json')) { + return JSON.stringify([ + { Status: 'running 2 minutes ago' }, + { Status: 'exited' }, + ]); + } + throw new Error(`unexpected execFileSync: ${args[0]} ${args[1]?.join(' ')}`); + }; + expect(wslOps.getWslHostInfo()).toEqual({ + version: { wsl: '2.9.3.0' }, + wslc: { running: 1, stopped: 1, total: 2 }, + }); + }); + + it('shows zero counts when wslc list is empty', () => { + execSyncImpl = (cmd) => { + if (String(cmd).includes('--version')) return 'WSL version: 2.9.3.0\n'; + throw new Error(`unexpected execSync: ${cmd}`); + }; + execFileSyncImpl = (...args) => { + if (args[0] === 'wslc' && args[1]?.[0] === '--version') return 'wslc 2.9.3.0\n'; + if (args[0] === 'wslc') return '[]'; + throw new Error(`unexpected execFileSync: ${args[0]}`); + }; + expect(wslOps.getWslHostInfo().wslc).toEqual({ running: 0, stopped: 0, total: 0 }); + }); + + it('returns null version and null wslc on legacy installs', () => { + mockWslListOnly(); + execFileSyncImpl = (...args) => { + if (args[0] === 'wslc') throw new Error('not found'); + throw new Error(`unexpected execFileSync: ${args[0]}`); + }; + expect(wslOps.getWslHostInfo()).toEqual({ version: null, wslc: null }); + }); +}); + +describe('runHostCleanupTask — WSL without wslc', () => { + it('skips gracefully (ok) without spawning when wslc is missing', async () => { + execFileSyncImpl = (...args) => { + if (args[0] === 'wslc') throw new Error('not found'); + return ''; + }; + const res = await wslOps.runHostCleanupTask({ + taskId: 'wslc-prune', + command: 'image prune|container prune', + }); + expect(res.ok).toBe(true); + expect(res.output).toMatch(/skipping WSL Containers cleanup/i); + expect(calls.spawn).toHaveLength(0); + }); +}); + +// ── Task availability on older WSL (no wslc) ───────────────────────────────── + +describe('task gating without wslc', () => { + const { TASKS } = require('../renderer/tasks'); + + function availableTasks(tools) { + return TASKS.filter(t => !t.requires || tools[t.requires]); + } + + it('excludes wslc-prune but keeps docker-prune when only docker is present', () => { + const tools = { docker: true, wslc: false }; + const ids = availableTasks(tools).map(t => t.id); + expect(ids).not.toContain('wslc-prune'); + expect(ids).toContain('docker-prune'); + }); + + it('includes wslc-prune only when wslc is detected on the host', () => { + const tools = { wslc: true }; + const ids = availableTasks(tools).map(t => t.id); + expect(ids).toContain('wslc-prune'); + }); + + it('host tasks are not run inside WSL (host flag set)', () => { + const wslcTask = TASKS.find(t => t.id === 'wslc-prune'); + expect(wslcTask.host).toBe(true); + expect(wslcTask.requires).toBe('wslc'); + }); +}); diff --git a/tests/wsl-config.test.js b/tests/wsl-config.test.js new file mode 100644 index 0000000..78490e6 --- /dev/null +++ b/tests/wsl-config.test.js @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const wslOps = require('../lib/wsl-ops'); + +describe('wslconfig INI sections (WSL 2.9+ experimental keys)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wslcfg-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes experimental keys under [experimental], not [wsl2]', () => { + const result = wslOps.writeWslConfig({ + wsl2: { memory: '8GB', networkingMode: 'mirrored' }, + experimental: { sparseVhd: 'true', autoMemoryReclaim: 'gradual' }, + }); + expect(result.ok).toBe(true); + const raw = fs.readFileSync(path.join(tmpDir, '.wslconfig'), 'utf8'); + expect(raw).toContain('[wsl2]'); + expect(raw).toContain('[experimental]'); + expect(raw).toContain('sparseVhd=true'); + const beforeExperimental = raw.split('[experimental]')[0]; + expect(beforeExperimental).not.toContain('sparseVhd'); + expect(beforeExperimental).not.toContain('autoMemoryReclaim'); + }); + + it('reads legacy experimental keys stored under [wsl2] without error', () => { + fs.writeFileSync(path.join(tmpDir, '.wslconfig'), [ + '[wsl2]', + 'memory=4GB', + 'sparseVhd=true', + 'autoMemoryReclaim=gradual', + '', + ].join('\n')); + const result = wslOps.readWslConfig(); + expect(result.ok).toBe(true); + expect(result.data.wsl2.memory).toBe('4GB'); + expect(result.data.wsl2.sparseVhd).toBe('true'); + expect(result.data.wsl2.autoMemoryReclaim).toBe('gradual'); + }); + + it('round-trips a correct [experimental] section', () => { + fs.writeFileSync(path.join(tmpDir, '.wslconfig'), [ + '[wsl2]', + 'memory=4GB', + '', + '[experimental]', + 'sparseVhd=true', + '', + ].join('\n')); + const read = wslOps.readWslConfig(); + expect(read.ok).toBe(true); + expect(read.data.experimental.sparseVhd).toBe('true'); + + const write = wslOps.writeWslConfig(read.data); + expect(write.ok).toBe(true); + const raw = fs.readFileSync(path.join(tmpDir, '.wslconfig'), 'utf8'); + expect(raw).toContain('[experimental]'); + expect(raw).toContain('sparseVhd=true'); + }); +}); diff --git a/tests/wsl-host.test.js b/tests/wsl-host.test.js new file mode 100644 index 0000000..00fc9d8 --- /dev/null +++ b/tests/wsl-host.test.js @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; + +const { parseWslVersionOutput, parseWslcContainerListJson } = require('../lib/wsl-ops'); + +describe('parseWslVersionOutput', () => { + it('parses English wsl --version output', () => { + const text = [ + 'WSL version: 2.9.3.0', + 'Kernel version: 6.18.35.2-1', + 'WSLg version: 1.0.79', + ].join('\n'); + expect(parseWslVersionOutput(text)).toEqual({ + wsl: '2.9.3.0', + kernel: '6.18.35.2-1', + }); + }); + + it('parses localized version lines when WSL label is present', () => { + const text = 'Версия WSL: 2.9.3.0\nВерсия ядра: 6.18.35.2-1'; + expect(parseWslVersionOutput(text)).toEqual({ + wsl: '2.9.3.0', + kernel: '6.18.35.2-1', + }); + }); + + it('parses Russian labels without ASCII Kernel prefix', () => { + expect(parseWslVersionOutput('Версия WSL: 2.7.10.0\nВерсия ядра: 6.6.87.2-1')).toEqual({ + wsl: '2.7.10.0', + kernel: '6.6.87.2-1', + }); + }); + + it('returns empty object for unrecognized text', () => { + expect(parseWslVersionOutput('not version info')).toEqual({}); + }); + + it('parses older store WSL versions (2.0.x, 2.7.x)', () => { + expect(parseWslVersionOutput('WSL version: 2.0.14.0\nKernel version: 5.15.146.1-2\n')).toEqual({ + wsl: '2.0.14.0', + kernel: '5.15.146.1-2', + }); + expect(parseWslVersionOutput('WSL version: 2.7.10.0\nKernel version: 6.6.87.2-1\n')).toEqual({ + wsl: '2.7.10.0', + kernel: '6.6.87.2-1', + }); + }); + + it('does not treat missing wsl --version output as a version', () => { + expect(parseWslVersionOutput('')).toEqual({}); + expect(parseWslVersionOutput('Invalid command line option: --version')).toEqual({}); + }); +}); + +describe('parseWslcContainerListJson', () => { + it('parses wslc ps --format json output', () => { + const json = JSON.stringify([ + { Status: 'running 1 minute ago' }, + { Status: 'exited' }, + ]); + expect(parseWslcContainerListJson(json)).toEqual({ + running: 1, + stopped: 1, + total: 2, + }); + }); + + it('returns zeros for an empty list', () => { + expect(parseWslcContainerListJson('[]')).toEqual({ + running: 0, + stopped: 0, + total: 0, + }); + }); + + it('returns null for invalid JSON', () => { + expect(parseWslcContainerListJson('not json')).toBeNull(); + }); +}); diff --git a/tests/wsl-ops-distro.test.js b/tests/wsl-ops-distro.test.js index f4d10ee..8ec2cd0 100644 --- a/tests/wsl-ops-distro.test.js +++ b/tests/wsl-ops-distro.test.js @@ -29,7 +29,8 @@ describe('distro management exports', () => { it('does not break existing exports', () => { const expectedExports = [ - 'wslEnv', 'checkWsl', 'detectTools', 'runCleanupTask', + 'wslEnv', 'checkWsl', 'detectTools', 'detectHostTools', 'getWslHostInfo', + 'runHostCleanupTask', 'runCleanupTask', 'findVhdx', 'getFileSize', 'runWslCommand', 'optimizeVhdx', 'estimateTaskSizes', 'scanDiskUsage', 'cancelDiskScan', 'getHealthInfo',