diff --git a/src/commands.ts b/src/commands.ts index 1f48d36a4..745e01a68 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -11,7 +11,6 @@ import { workspaceStatusLabel, } from "./api/api-helper"; import * as cliExec from "./core/cliExec"; -import { appendVsCodeLogs } from "./core/supportBundleLogs"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/errorUtils"; import { type FeatureSet, featureSetForVersion } from "./featureSet"; @@ -26,6 +25,7 @@ import { applySettingOverrides, } from "./remote/sshOverrides"; import { resolveCliAuth } from "./settings/cli"; +import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; import { toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; @@ -304,7 +304,7 @@ export class Commands { await appendVsCodeLogs( outputUri.fsPath, { - remoteSshLogPath: this.workspaceLogPath, + activeProxyLogPath: this.workspaceLogPath, proxyLogDir: this.pathResolver.getProxyLogPath(), extensionLogDir: this.pathResolver.getCodeLogDir(), }, diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts deleted file mode 100644 index e21551994..000000000 --- a/src/core/supportBundleLogs.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { unzip, zip, type Zippable } from "fflate"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { promisify } from "node:util"; - -import { type Logger } from "../logging/logger"; -import { renameWithRetry } from "../util/fs"; - -export interface LogSources { - remoteSshLogPath?: string; - proxyLogDir?: string; - extensionLogDir?: string; -} - -// 3 days is enough context for recent issues; matching the 7-day -// rotation would bloat the bundle. -const LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; - -const unzipAsync = promisify(unzip); -const zipAsync = promisify(zip); - -async function collectDirFiles( - dirPath: string, - logger: Logger, -): Promise> { - const results = new Map(); - - let entries: string[]; - try { - entries = await fs.readdir(dirPath); - } catch (error) { - logger.warn(`Could not read log directory ${dirPath}`, error); - return results; - } - - const cutoff = Date.now() - LOG_MAX_AGE_MS; - - await Promise.all( - entries.map(async (entry) => { - const filePath = path.join(dirPath, entry); - try { - const stat = await fs.stat(filePath); - if (!stat.isFile() || stat.mtimeMs < cutoff) { - return; - } - results.set(entry, await fs.readFile(filePath)); - } catch (error) { - logger.warn(`Could not read log file ${filePath}`, error); - } - }), - ); - - return results; -} - -/** - * Gather log files from each source independently so a failure in one - * does not affect the others. - */ -async function collectLogFiles( - sources: LogSources, - logger: Logger, -): Promise> { - const files = new Map(); - - if (sources.remoteSshLogPath) { - try { - files.set( - `vscode-logs/remote-ssh/${path.basename(sources.remoteSshLogPath)}`, - await fs.readFile(sources.remoteSshLogPath), - ); - } catch (error) { - logger.warn("Could not read Remote SSH log", error); - } - } - - if (sources.proxyLogDir) { - for (const [name, data] of await collectDirFiles( - sources.proxyLogDir, - logger, - )) { - files.set(`vscode-logs/proxy/${name}`, data); - } - } - - if (sources.extensionLogDir) { - for (const [name, data] of await collectDirFiles( - sources.extensionLogDir, - logger, - )) { - files.set(`vscode-logs/extension/${name}`, data); - } - } - - return files; -} - -/** - * Best-effort: append VS Code logs to a support bundle zip. - * Uses atomic rename to avoid corrupting the original bundle on failure. - */ -export async function appendVsCodeLogs( - zipPath: string, - sources: LogSources, - logger: Logger, -): Promise { - try { - const logFiles = await collectLogFiles(sources, logger); - if (logFiles.size === 0) { - logger.info("No VS Code logs found to add to support bundle"); - return; - } - - logger.info( - `Adding ${logFiles.size} VS Code log file(s) to support bundle`, - ); - - // Write to a named temporary path first so a failure at the rename step - // leaves the user with a properly named file containing VS Code logs. - const parsed = path.parse(zipPath); - const vscodeBundlePath = path.join( - parsed.dir, - `${parsed.name}-vscode${parsed.ext}`, - ); - - try { - const entries: Zippable = await unzipAsync(await fs.readFile(zipPath)); - for (const [name, data] of logFiles) { - entries[name] = data; - } - const updated = await zipAsync(entries); - await fs.writeFile(vscodeBundlePath, updated); - } catch (error) { - logger.error("Failed to add VS Code logs to support bundle", error); - try { - await fs.rm(vscodeBundlePath, { force: true }); - } catch (cleanupError) { - logger.warn( - `Could not clean up partial bundle at ${vscodeBundlePath}`, - cleanupError, - ); - } - return; - } - - try { - await renameWithRetry(fs.rename, vscodeBundlePath, zipPath); - } catch (error) { - logger.warn( - `Could not replace original bundle; VS Code logs saved separately at ${vscodeBundlePath}`, - error, - ); - } - } catch (error) { - // Best-effort: never let a failure here lose the user's bundle. - logger.error("Unexpected error appending VS Code logs", error); - } -} diff --git a/src/remote/sshExtension.ts b/src/remote/sshExtension.ts index 70ed849da..eae3d5ca4 100644 --- a/src/remote/sshExtension.ts +++ b/src/remote/sshExtension.ts @@ -10,6 +10,28 @@ export const REMOTE_SSH_EXTENSION_IDS = [ export type RemoteSshExtensionId = (typeof REMOTE_SSH_EXTENSION_IDS)[number]; +/** + * VS Code Remote-SSH log layout, shared by the live SSH monitor and the + * support-bundle collector so a future layout change updates one place. + */ +const OUTPUT_LOGGING_DIR_PREFIX = "output_logging_"; +const REMOTE_SSH_LOG_NAME_FRAGMENT = "Remote - SSH"; + +/** True if `dirName` is the exthost dir of a known Remote-SSH extension. */ +export function isRemoteSshExtensionDir(dirName: string): boolean { + return (REMOTE_SSH_EXTENSION_IDS as readonly string[]).includes(dirName); +} + +/** True if `dirName` is a VS Code shared output channel dir. */ +export function isOutputLoggingDir(dirName: string): boolean { + return dirName.startsWith(OUTPUT_LOGGING_DIR_PREFIX); +} + +/** True if `fileName` is the Remote-SSH log inside a shared output channel. */ +export function isSharedChannelRemoteSshLog(fileName: string): boolean { + return fileName.includes(REMOTE_SSH_LOG_NAME_FRAGMENT); +} + type RemoteSshExtension = vscode.Extension & { id: RemoteSshExtensionId; }; diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index 77b1d68a6..58d0fee49 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -8,6 +8,10 @@ import { findPort } from "../util"; import { cleanupFiles } from "../util/fileCleanup"; import { NetworkStatusReporter } from "./networkStatus"; +import { + isOutputLoggingDir, + isSharedChannelRemoteSshLog, +} from "./sshExtension"; import type { Logger } from "../logging/logger"; import type { TelemetryReporter } from "../telemetry/reporter"; @@ -514,7 +518,7 @@ async function findRemoteSshLogPath( try { const dirs = await fs.readdir(logsParentDir); const outputDirs = dirs - .filter((d) => d.startsWith("output_logging_")) + .filter(isOutputLoggingDir) .sort((a, b) => a.localeCompare(b)) .reverse(); @@ -541,6 +545,6 @@ async function findRemoteSshLogPath( async function findSshLogInDir(dirPath: string): Promise { const files = await fs.readdir(dirPath); - const remoteSshLog = files.find((f) => f.includes("Remote - SSH")); + const remoteSshLog = files.find(isSharedChannelRemoteSshLog); return remoteSshLog ? path.join(dirPath, remoteSshLog) : undefined; } diff --git a/src/supportBundle/appendVsCodeLogs.ts b/src/supportBundle/appendVsCodeLogs.ts new file mode 100644 index 000000000..169739bb0 --- /dev/null +++ b/src/supportBundle/appendVsCodeLogs.ts @@ -0,0 +1,98 @@ +import { unzip, zip, type Zippable } from "fflate"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { promisify } from "node:util"; + +import { type Logger } from "../logging/logger"; +import { renameWithRetry } from "../util/fs"; + +import { collectVsCodeDiagnostics, type LogSources } from "./logFiles"; + +export type { LogSources } from "./logFiles"; + +const unzipAsync = promisify(unzip); +const zipAsync = promisify(zip); + +function vscodeBundlePath(zipPath: string): string { + const parsed = path.parse(zipPath); + return path.join( + parsed.dir, + `${parsed.name}-vscode-${randomUUID().slice(0, 8)}${parsed.ext}`, + ); +} + +async function writeBundleWithDiagnostics( + zipPath: string, + outputPath: string, + diagnosticFiles: Map, +): Promise { + const sourceMode = (await fs.stat(zipPath)).mode & 0o777; + const entries: Zippable = await unzipAsync(await fs.readFile(zipPath)); + + for (const [name, data] of diagnosticFiles) { + entries[name] = data; + } + + // Set mode at create time: no umask window, no separate chmod that + // could fail on filesystems that don't honor mode bits. + await fs.writeFile(outputPath, await zipAsync(entries), { mode: sourceMode }); +} + +/** + * Best-effort: append VS Code logs to a support bundle zip. + * Uses atomic rename to avoid corrupting the original bundle on failure. + */ +export async function appendVsCodeLogs( + zipPath: string, + sources: LogSources, + logger: Logger, +): Promise { + try { + const diagnosticFiles = await collectVsCodeDiagnostics(sources, logger); + if (diagnosticFiles.size === 0) { + logger.info("No VS Code diagnostics found to add to support bundle"); + return; + } + + logger.info( + `Adding ${diagnosticFiles.size} VS Code diagnostic file(s) to support bundle`, + ); + + const outputBundlePath = vscodeBundlePath(zipPath); + try { + await writeBundleWithDiagnostics( + zipPath, + outputBundlePath, + diagnosticFiles, + ); + } catch (error) { + logger.error( + "Failed to add VS Code diagnostics to support bundle", + error, + ); + + try { + await fs.rm(outputBundlePath, { force: true }); + } catch (cleanupError) { + logger.warn( + `Could not clean up partial bundle at ${outputBundlePath}`, + cleanupError, + ); + } + return; + } + + try { + await renameWithRetry(fs.rename, outputBundlePath, zipPath); + } catch (error) { + logger.warn( + `Could not replace original bundle; VS Code diagnostics saved separately at ${outputBundlePath}`, + error, + ); + } + } catch (error) { + // Best-effort: never let a failure here lose the user's bundle. + logger.error("Unexpected error appending VS Code diagnostics", error); + } +} diff --git a/src/supportBundle/files.ts b/src/supportBundle/files.ts new file mode 100644 index 000000000..39f7e0978 --- /dev/null +++ b/src/supportBundle/files.ts @@ -0,0 +1,173 @@ +import { type Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { type Logger } from "../logging/logger"; + +export interface CollectedFile { + data: Uint8Array; + mtimeMs: number; + relativePath: string; +} + +/** 3-day window; the 7-day rotation would bloat the bundle. */ +const LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; +const MAX_LOG_SCAN_DEPTH = 6; +/** Per-file cap to prevent OOM on multi-GB debug logs. */ +const MAX_LOG_FILE_BYTES = 50 * 1024 * 1024; + +// Accept .log and VS Code's rotated .log.N form. +export const isLogFile = (name: string): boolean => /\.log(\.\d+)?$/.test(name); + +export function normalizeZipPath(filePath: string): string { + return filePath.replaceAll(path.sep, "/"); +} + +export function addFiles( + target: Map, + source: Map, +): void { + for (const [name, data] of source) { + target.set(name, data); + } +} + +export function prefixFiles( + prefix: string, + files: Map, +): Map { + return new Map([...files].map(([name, data]) => [`${prefix}/${name}`, data])); +} + +function isEnoent(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +/** Read the last `length` bytes of `filePath`. */ +async function readTail(filePath: string, length: number): Promise { + const handle = await fs.open(filePath, "r"); + try { + const { size } = await handle.stat(); + const offset = Math.max(0, size - length); + const readLen = Math.min(length, size); + const buf = Buffer.alloc(readLen); + const { bytesRead } = await handle.read(buf, 0, readLen, offset); + return buf.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} + +/** lstat-guarded read; tail-truncates files over `MAX_LOG_FILE_BYTES`. */ +export async function readLogFile( + filePath: string, + logger: Logger, +): Promise<{ data: Uint8Array; mtimeMs: number } | undefined> { + try { + const stat = await fs.lstat(filePath); + if (!stat.isFile()) { + return undefined; + } + if (stat.size > MAX_LOG_FILE_BYTES) { + logger.warn( + `Truncating log file ${filePath} (${stat.size} bytes) to last ${MAX_LOG_FILE_BYTES} bytes`, + ); + return { + data: await readTail(filePath, MAX_LOG_FILE_BYTES), + mtimeMs: stat.mtimeMs, + }; + } + return { data: await fs.readFile(filePath), mtimeMs: stat.mtimeMs }; + } catch (error) { + logger.warn(`Could not read log file ${filePath}`, error); + return undefined; + } +} + +async function readRecentFile( + filePath: string, + logger: Logger, +): Promise<{ data: Uint8Array; mtimeMs: number } | undefined> { + const file = await readLogFile(filePath, logger); + if (!file || file.mtimeMs < Date.now() - LOG_MAX_AGE_MS) { + return undefined; + } + return file; +} + +export async function readDirents( + dirPath: string, + logger: Logger, + warnOnMissing = true, +): Promise { + try { + return await fs.readdir(dirPath, { withFileTypes: true }); + } catch (error) { + if (warnOnMissing || !isEnoent(error)) { + logger.warn(`Could not read log directory ${dirPath}`, error); + } + return []; + } +} + +export async function collectDirFiles( + dirPath: string, + logger: Logger, + filter: (name: string) => boolean = () => true, + warnOnMissing = true, +): Promise> { + const results = new Map(); + const entries = await readDirents(dirPath, logger, warnOnMissing); + + await Promise.all( + entries.map(async (entry) => { + if (!entry.isFile() || !filter(entry.name)) { + return; + } + const file = await readRecentFile(path.join(dirPath, entry.name), logger); + if (file) { + results.set(entry.name, file.data); + } + }), + ); + return results; +} + +export async function collectMatchingFiles( + rootPath: string, + logger: Logger, + matches: (relativePath: string, fileName: string) => boolean, +): Promise { + const results: CollectedFile[] = []; + + async function walk(dirPath: string, depth: number): Promise { + // Silence ENOENT on descents; VS Code log rotation races are normal. + const entries = await readDirents(dirPath, logger, depth === 0); + await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + if (depth < MAX_LOG_SCAN_DEPTH) { + await walk(entryPath, depth + 1); + } + return; + } + const relativePath = path.relative(rootPath, entryPath); + if (!entry.isFile() || !matches(relativePath, entry.name)) { + return; + } + const file = await readRecentFile(entryPath, logger); + if (file) { + results.push({ ...file, relativePath }); + } + }), + ); + } + + await walk(rootPath, 0); + return results; +} diff --git a/src/supportBundle/logFiles.ts b/src/supportBundle/logFiles.ts new file mode 100644 index 000000000..288fb3024 --- /dev/null +++ b/src/supportBundle/logFiles.ts @@ -0,0 +1,269 @@ +import * as path from "node:path"; + +import { type Logger } from "../logging/logger"; +import { + isOutputLoggingDir, + isRemoteSshExtensionDir, + isSharedChannelRemoteSshLog, +} from "../remote/sshExtension"; + +import { + addFiles, + collectDirFiles, + collectMatchingFiles, + type CollectedFile, + isLogFile, + normalizeZipPath, + prefixFiles, + readDirents, + readLogFile, +} from "./files"; +import { collectSettingsFile } from "./settings"; + +export interface LogSources { + activeProxyLogPath?: string; + proxyLogDir?: string; + extensionLogDir?: string; +} + +interface WindowLogDir { + relativePath: string; + windowPath: string; +} + +interface LogContext { + currentWindowPath: string; + logsRoot: string; +} + +// Coder CLI writes either `coder-ssh-*.log` or bare `.log`. +const isProxyLogFile = (name: string): boolean => + isLogFile(name) && (name.startsWith("coder-ssh") || /^\d+\.log$/.test(name)); + +function isRemoteSshLog(relativePath: string, fileName: string): boolean { + if (!isLogFile(fileName)) { + return false; + } + const parts = normalizeZipPath(relativePath).split("/"); + // Whole exthost dir belongs to one extension; output_logging_* is shared. + if (parts.some(isRemoteSshExtensionDir)) { + return true; + } + return ( + parts.some(isOutputLoggingDir) && isSharedChannelRemoteSshLog(fileName) + ); +} + +function newestLog(logs: CollectedFile[]): CollectedFile | undefined { + // Lexicographic tie-break (not localeCompare) so the choice is locale-stable. + return logs.toSorted((a, b) => { + if (b.mtimeMs !== a.mtimeMs) { + return b.mtimeMs - a.mtimeMs; + } + if (b.relativePath === a.relativePath) { + return 0; + } + return b.relativePath > a.relativePath ? 1 : -1; + })[0]; +} + +export function resolveLogContext( + extensionLogDir: string, +): LogContext | undefined { + const resolved = path.resolve(extensionLogDir); + const exthostDir = path.dirname(resolved); + const windowDir = path.dirname(exthostDir); + const windowName = path.basename(windowDir); + const sessionDir = path.dirname(windowDir); + + // Trust the layout, not the literal id: forks may rebrand the id. + if ( + path.basename(exthostDir) !== "exthost" || + !/^window\d+$/i.test(windowName) + ) { + return undefined; + } + + const sessionName = path.basename(sessionDir); + return { + currentWindowPath: windowDir, + // Anchored so `20240101T000000-foo` doesn't widen logsRoot. + logsRoot: /^\d{8}T\d{6}$/.test(sessionName) + ? path.dirname(sessionDir) + : sessionDir, + }; +} + +export async function collectWindowLogDirs( + logsRoot: string, + logger: Logger, +): Promise { + const windows: WindowLogDir[] = []; + await Promise.all( + (await readDirents(logsRoot, logger)).map(async (entry) => { + if (!entry.isDirectory()) return; + const entryPath = path.join(logsRoot, entry.name); + if (/^window\d+$/i.test(entry.name)) { + windows.push({ relativePath: entry.name, windowPath: entryPath }); + return; + } + for (const sub of await readDirents(entryPath, logger)) { + if (sub.isDirectory() && /^window\d+$/i.test(sub.name)) { + windows.push({ + relativePath: `${entry.name}/${sub.name}`, + windowPath: path.join(entryPath, sub.name), + }); + } + } + }), + ); + return windows.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +async function collectProxyLogs( + sources: LogSources, + logger: Logger, +): Promise> { + const files = new Map(); + const activeBasename = sources.activeProxyLogPath + ? path.basename(sources.activeProxyLogPath) + : undefined; + + if (sources.activeProxyLogPath && activeBasename) { + // No age cutoff: long-lived sessions can have mtime past the window. + const file = await readLogFile(sources.activeProxyLogPath, logger); + if (file) { + files.set(`vscode-logs/proxy/${activeBasename}`, file.data); + } + } + + if (sources.proxyLogDir) { + addFiles( + files, + prefixFiles( + "vscode-logs/proxy", + // Active log was already added above; don't double-bundle. + await collectDirFiles( + sources.proxyLogDir, + logger, + (name) => isProxyLogFile(name) && name !== activeBasename, + ), + ), + ); + } + return files; +} + +async function collectVsCodeWindowLogs( + extensionLogDir: string, + logger: Logger, +): Promise> { + const files = new Map(); + const context = resolveLogContext(extensionLogDir); + + if (!context) { + // Non-canonical layout: scan the extension dir + assumed window dir + // (one level up, or two if the parent is `exthost`). + addFiles( + files, + prefixFiles( + "vscode-logs/extension", + await collectDirFiles(extensionLogDir, logger, isLogFile), + ), + ); + const exthostDir = path.dirname(extensionLogDir); + const windowDir = + path.basename(exthostDir) === "exthost" + ? path.dirname(exthostDir) + : exthostDir; + for (const log of await collectMatchingFiles( + windowDir, + logger, + isRemoteSshLog, + )) { + files.set( + `vscode-logs/remote-ssh/${normalizeZipPath(log.relativePath)}`, + log.data, + ); + } + return files; + } + + const extensionId = path.basename(extensionLogDir); + const currentWindowSshLogs: CollectedFile[] = []; + + for (const window of await collectWindowLogDirs(context.logsRoot, logger)) { + const extLogs = await collectDirFiles( + path.join(window.windowPath, "exthost", extensionId), + logger, + isLogFile, + false, + ); + // Skip windows that never hosted Coder; their SSH logs aren't ours. + if (extLogs.size === 0) continue; + + addFiles( + files, + prefixFiles( + `vscode-logs/extension/${normalizeZipPath(window.relativePath)}`, + extLogs, + ), + ); + + const isCurrent = window.windowPath === context.currentWindowPath; + for (const sshLog of await collectMatchingFiles( + window.windowPath, + logger, + isRemoteSshLog, + )) { + const relativePath = normalizeZipPath( + path.join(window.relativePath, sshLog.relativePath), + ); + files.set(`vscode-logs/remote-ssh/${relativePath}`, sshLog.data); + if (isCurrent) { + currentWindowSshLogs.push({ ...sshLog, relativePath }); + } + } + } + + // Current window only; falling back to others would mislabel a stale log. + const activeLog = newestLog(currentWindowSshLogs); + if (activeLog) { + files.set( + `vscode-logs/remote-ssh/${path.basename(activeLog.relativePath)}`, + activeLog.data, + ); + } + return files; +} + +export async function collectSupportLogFiles( + sources: LogSources, + logger: Logger, +): Promise> { + const files = await collectProxyLogs(sources, logger); + if (sources.extensionLogDir) { + addFiles( + files, + await collectVsCodeWindowLogs(sources.extensionLogDir, logger), + ); + } + return files; +} + +/** + * Collects proxy logs, Remote-SSH and extension logs across recent windows, + * and a redacted `coder.*` / `remote.*` settings snapshot. Keys are + * zip-relative paths under `vscode-logs/`. + */ +export async function collectVsCodeDiagnostics( + sources: LogSources, + logger: Logger, +): Promise> { + const files = await collectSupportLogFiles(sources, logger); + const settings = collectSettingsFile(logger); + if (settings) { + files.set("vscode-logs/settings.json", settings); + } + return files; +} diff --git a/src/supportBundle/settings.ts b/src/supportBundle/settings.ts new file mode 100644 index 000000000..85b169307 --- /dev/null +++ b/src/supportBundle/settings.ts @@ -0,0 +1,113 @@ +import * as vscode from "vscode"; + +import { type Logger } from "../logging/logger"; + +// Paths, hostnames, URLs, and command strings: anything user-supplied that +// could identify a machine or deployment in a shared bundle. +const REDACTED_SETTINGS: ReadonlySet = new Set([ + "coder.binaryDestination", + "coder.binarySource", + "coder.defaultUrl", + "coder.globalFlags", + "coder.headerCommand", + "coder.proxyBypass", + "coder.proxyLogDirectory", + "coder.sshConfig", + "coder.sshFlags", + "coder.tlsAltHost", + "coder.tlsCaFile", + "coder.tlsCertFile", + "coder.tlsCertRefreshCommand", + "coder.tlsKeyFile", +]); + +// Explicit allowlist instead of package.json discovery: discovery requires +// the extension to be installed (it isn't for tests/headless runs) and +// silently misses settings declared via contributes.configurationDefaults. +const COLLECTED_SETTINGS: readonly string[] = [ + ...REDACTED_SETTINGS, + "coder.autologin", + "coder.disableNotifications", + "coder.disableSignatureVerification", + "coder.disableUpdateNotifications", + "coder.enableDownloads", + "coder.experimental.oauth", + "coder.httpClientLogLevel", + "coder.insecure", + "coder.networkThreshold.latencyMs", + "coder.telemetry.level", + "coder.telemetry.local", + "coder.useKeyring", + "remote.SSH.connectTimeout", + "remote.SSH.logLevel", + "remote.SSH.reconnectionGraceTime", + "remote.SSH.serverShutdownTimeout", + "remote.SSH.useExecServer", + "remote.SSH.useLocalServer", + "remote.autoForwardPorts", +].sort(); + +type SettingValue = unknown; +type SettingInspection = Record; + +function redactedSettingValue(value: SettingValue): string { + const emptyArray = Array.isArray(value) && value.length === 0; + return value === undefined || value === null || value === "" || emptyArray + ? "" + : ""; +} + +/** inspect() metadata + public package.json default; not user-supplied. */ +const REDACTION_PASSTHROUGH: ReadonlySet = new Set([ + "key", + "languageIds", + "defaultValue", +]); + +function maybeRedact( + key: string, + name: string, + value: SettingValue, +): SettingValue { + if (REDACTION_PASSTHROUGH.has(name)) { + return value; + } + return REDACTED_SETTINGS.has(key) ? redactedSettingValue(value) : value; +} + +function collectSettingsDiagnostics(): Record { + const config = vscode.workspace.getConfiguration(); + const diagnostics: Record = {}; + for (const key of COLLECTED_SETTINGS) { + const inspected = config.inspect(key); + if (!inspected) { + continue; + } + const entry: SettingInspection = { + effective: maybeRedact(key, "effective", config.get(key)), + }; + for (const [name, value] of Object.entries(inspected)) { + entry[name] = maybeRedact(key, name, value); + } + diagnostics[key] = entry; + } + return diagnostics; +} + +/** + * Returns a UTF-8 JSON snapshot of `inspect()` output for the allowlisted + * `coder.*` / `remote.*` settings. Sensitive values (paths, hostnames, + * URLs, commands) are replaced with `` or ``. + */ +export function collectSettingsFile(logger: Logger): Uint8Array | undefined { + try { + const diagnostics = collectSettingsDiagnostics(); + if (Object.keys(diagnostics).length === 0) { + return undefined; + } + return Buffer.from(`${JSON.stringify(diagnostics, null, "\t")}\n`); + } catch (error) { + logger.warn("Could not collect VS Code settings", error); + return undefined; + } +} diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts deleted file mode 100644 index e942e40e4..000000000 --- a/test/unit/core/supportBundleLogs.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { strToU8, unzipSync, zipSync } from "fflate"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { appendVsCodeLogs } from "@/core/supportBundleLogs"; -import { renameWithRetry } from "@/util/fs"; - -import { createMockLogger } from "../../mocks/testHelpers"; - -// Wrap renameWithRetry so individual tests can override it via -// mockRejectedValueOnce; by default it calls through to the real impl. -vi.mock("@/util/fs", async () => { - const actual = await vi.importActual("@/util/fs"); - return { ...actual, renameWithRetry: vi.fn(actual.renameWithRetry) }; -}); - -// chmod to 0o000 is a no-op as root and on Windows. -const canTestUnreadable = - process.getuid?.() !== 0 && process.platform !== "win32"; - -let tmpDir: string; - -beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "support-bundle-")); -}); - -afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); -}); - -const logger = createMockLogger(); - -/** Set a file's mtime to N days in the past. */ -async function setAge(filePath: string, daysAgo: number): Promise { - const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); - await fs.utimes(filePath, past, past); -} - -async function makeBundle(): Promise { - const zipPath = path.join(tmpDir, "coder-support-123.zip"); - await fs.writeFile( - zipPath, - zipSync({ "server/info.txt": strToU8("server data") }), - ); - return zipPath; -} - -async function readZip(zipPath: string): Promise> { - const entries = unzipSync(await fs.readFile(zipPath)); - return Object.fromEntries( - Object.entries(entries).map(([name, data]) => [ - name, - Buffer.from(data).toString(), - ]), - ); -} - -function vsCodeLogKeys(entries: Record): string[] { - return Object.keys(entries) - .filter((k) => k.startsWith("vscode-logs/")) - .sort(); -} - -describe("appendVsCodeLogs", () => { - it("merges logs from all three sources and skips subdirectories", async () => { - const zipPath = await makeBundle(); - - const sshLog = path.join(tmpDir, "ssh.log"); - await fs.writeFile(sshLog, "ssh"); - - const proxyDir = path.join(tmpDir, "proxy"); - await fs.mkdir(proxyDir); - await fs.writeFile(path.join(proxyDir, "coder-ssh-recent.log"), "proxy"); - await fs.mkdir(path.join(proxyDir, "subdir")); - - const extDir = path.join(tmpDir, "ext"); - await fs.mkdir(extDir); - await fs.writeFile(path.join(extDir, "Coder.log"), "ext"); - - await appendVsCodeLogs( - zipPath, - { - remoteSshLogPath: sshLog, - proxyLogDir: proxyDir, - extensionLogDir: extDir, - }, - logger, - ); - - const entries = await readZip(zipPath); - expect(Object.keys(entries).sort()).toEqual([ - "server/info.txt", - "vscode-logs/extension/Coder.log", - "vscode-logs/proxy/coder-ssh-recent.log", - "vscode-logs/remote-ssh/ssh.log", - ]); - expect(entries["server/info.txt"]).toBe("server data"); - expect(entries["vscode-logs/proxy/coder-ssh-recent.log"]).toBe("proxy"); - }); - - it("does not touch the zip when no logs are found", async () => { - const zipPath = await makeBundle(); - const beforeStat = await fs.stat(zipPath); - const beforeBytes = await fs.readFile(zipPath); - - await appendVsCodeLogs(zipPath, {}, logger); - - expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); - expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); - }); - - it("merges a large number of files without dropping any", async () => { - const zipPath = await makeBundle(); - - const proxyDir = path.join(tmpDir, "proxy"); - const extDir = path.join(tmpDir, "ext"); - await fs.mkdir(proxyDir); - await fs.mkdir(extDir); - - const fileCount = 60; - await Promise.all( - Array.from({ length: fileCount }, (_, i) => - Promise.all([ - fs.writeFile(path.join(proxyDir, `proxy-${i}.log`), `proxy-${i}`), - fs.writeFile(path.join(extDir, `ext-${i}.log`), `ext-${i}`), - ]), - ), - ); - - await appendVsCodeLogs( - zipPath, - { proxyLogDir: proxyDir, extensionLogDir: extDir }, - logger, - ); - - const entries = await readZip(zipPath); - const keys = vsCodeLogKeys(entries); - expect(keys).toHaveLength(fileCount * 2); - for (let i = 0; i < fileCount; i++) { - expect(entries[`vscode-logs/proxy/proxy-${i}.log`]).toBe(`proxy-${i}`); - expect(entries[`vscode-logs/extension/ext-${i}.log`]).toBe(`ext-${i}`); - } - }); - - it("filters proxy logs older than 3 days by mtime", async () => { - const zipPath = await makeBundle(); - - const proxyDir = path.join(tmpDir, "proxy"); - await fs.mkdir(proxyDir); - await fs.writeFile(path.join(proxyDir, "recent.log"), "recent"); - await fs.writeFile(path.join(proxyDir, "old.log"), "old"); - await setAge(path.join(proxyDir, "old.log"), 5); - - await appendVsCodeLogs(zipPath, { proxyLogDir: proxyDir }, logger); - - expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ - "vscode-logs/proxy/recent.log", - ]); - }); - - it("filters extension logs older than 3 days by mtime", async () => { - const zipPath = await makeBundle(); - - const extDir = path.join(tmpDir, "ext"); - await fs.mkdir(extDir); - await fs.writeFile(path.join(extDir, "Coder-recent.log"), "recent"); - await fs.writeFile(path.join(extDir, "Coder-old.log"), "old"); - await setAge(path.join(extDir, "Coder-old.log"), 5); - - await appendVsCodeLogs(zipPath, { extensionLogDir: extDir }, logger); - - expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ - "vscode-logs/extension/Coder-recent.log", - ]); - }); - - it.runIf(canTestUnreadable)( - "skips missing or unreadable sources and includes the rest", - async () => { - const zipPath = await makeBundle(); - - const proxyDir = path.join(tmpDir, "proxy"); - await fs.mkdir(proxyDir); - await fs.writeFile(path.join(proxyDir, "good.log"), "ok"); - const badLog = path.join(proxyDir, "bad.log"); - await fs.writeFile(badLog, "secret"); - await fs.chmod(badLog, 0o000); - - try { - await appendVsCodeLogs( - zipPath, - { - remoteSshLogPath: path.join(tmpDir, "nonexistent.log"), - proxyLogDir: proxyDir, - extensionLogDir: path.join(tmpDir, "no-such-dir"), - }, - logger, - ); - - expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ - "vscode-logs/proxy/good.log", - ]); - } finally { - await fs.chmod(badLog, 0o644); - } - }, - ); - - it("keeps the -vscode.zip sibling when rename fails", async () => { - const zipPath = await makeBundle(); - const beforeStat = await fs.stat(zipPath); - const beforeBytes = await fs.readFile(zipPath); - - const sshLog = path.join(tmpDir, "ssh.log"); - await fs.writeFile(sshLog, "ssh content"); - - vi.mocked(renameWithRetry).mockRejectedValueOnce( - new Error("simulated rename failure"), - ); - - await appendVsCodeLogs(zipPath, { remoteSshLogPath: sshLog }, logger); - - expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); - expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); - - const siblingPath = path.join(tmpDir, "coder-support-123-vscode.zip"); - const entries = await readZip(siblingPath); - expect(Object.keys(entries).sort()).toEqual([ - "server/info.txt", - "vscode-logs/remote-ssh/ssh.log", - ]); - expect(entries["vscode-logs/remote-ssh/ssh.log"]).toBe("ssh content"); - }); - - it("leaves the original zip intact and cleans up the partial sibling when corrupted", async () => { - const zipPath = path.join(tmpDir, "coder-support-123.zip"); - await fs.writeFile(zipPath, "not a zip"); - const beforeStat = await fs.stat(zipPath); - const beforeBytes = await fs.readFile(zipPath); - - const logPath = path.join(tmpDir, "ssh.log"); - await fs.writeFile(logPath, "content"); - - await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); - - expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); - expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); - expect(await fs.readdir(tmpDir)).not.toContain( - "coder-support-123-vscode.zip", - ); - }); -}); diff --git a/test/unit/supportBundle/appendVsCodeLogs.test.ts b/test/unit/supportBundle/appendVsCodeLogs.test.ts new file mode 100644 index 000000000..9d0b61dc0 --- /dev/null +++ b/test/unit/supportBundle/appendVsCodeLogs.test.ts @@ -0,0 +1,180 @@ +import { strToU8, unzipSync, zipSync } from "fflate"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { appendVsCodeLogs } from "@/supportBundle/appendVsCodeLogs"; +import { collectVsCodeDiagnostics } from "@/supportBundle/logFiles"; +import { renameWithRetry } from "@/util/fs"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +const collectVsCodeDiagnosticsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/supportBundle/logFiles", () => ({ + collectVsCodeDiagnostics: collectVsCodeDiagnosticsMock, +})); + +// Wrap renameWithRetry so individual tests can override it via +// mockRejectedValueOnce; by default it calls through to the real impl. +vi.mock("@/util/fs", async () => { + const actual = await vi.importActual("@/util/fs"); + return { ...actual, renameWithRetry: vi.fn(actual.renameWithRetry) }; +}); + +const canTestFileMode = process.platform !== "win32"; +let tmpDir: string; +const logger = createMockLogger(); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "support-bundle-")); + vi.mocked(collectVsCodeDiagnostics).mockReset(); + vi.mocked(renameWithRetry).mockClear(); + vi.mocked(logger.error).mockClear(); + vi.mocked(logger.warn).mockClear(); + vi.mocked(logger.info).mockClear(); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function makeBundle(): Promise { + const zipPath = path.join(tmpDir, "coder-support-123.zip"); + await fs.writeFile( + zipPath, + zipSync({ "server/info.txt": strToU8("server data") }), + ); + return zipPath; +} + +async function readZip(zipPath: string): Promise> { + const entries = unzipSync(await fs.readFile(zipPath)); + return Object.fromEntries( + Object.entries(entries).map(([name, data]) => [ + name, + Buffer.from(data).toString(), + ]), + ); +} + +async function findBundleSibling(): Promise { + const sibling = (await fs.readdir(tmpDir)).find((name) => + /^coder-support-123-vscode-[a-f0-9]{8}\.zip$/.test(name), + ); + if (!sibling) { + throw new Error("support bundle sibling not found"); + } + return path.join(tmpDir, sibling); +} + +describe("appendVsCodeLogs", () => { + it("adds collected diagnostics to the support bundle", async () => { + const zipPath = await makeBundle(); + vi.mocked(collectVsCodeDiagnostics).mockResolvedValue( + new Map([ + ["vscode-logs/settings.json", Buffer.from("settings")], + ["vscode-logs/proxy/active.log", Buffer.from("proxy")], + ]), + ); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect(await readZip(zipPath)).toEqual({ + "server/info.txt": "server data", + "vscode-logs/proxy/active.log": "proxy", + "vscode-logs/settings.json": "settings", + }); + }); + + it("does not rewrite the bundle when no diagnostics are found", async () => { + const zipPath = await makeBundle(); + const beforeBytes = await fs.readFile(zipPath); + vi.mocked(collectVsCodeDiagnostics).mockResolvedValue(new Map()); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + expect(vi.mocked(renameWithRetry)).not.toHaveBeenCalled(); + }); + + it("keeps a VS Code bundle sibling when replacing the original fails", async () => { + const zipPath = await makeBundle(); + const beforeBytes = await fs.readFile(zipPath); + vi.mocked(collectVsCodeDiagnostics).mockResolvedValue( + new Map([["vscode-logs/proxy/active.log", Buffer.from("proxy")]]), + ); + vi.mocked(renameWithRetry).mockRejectedValueOnce( + new Error("simulated rename failure"), + ); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + expect(await readZip(await findBundleSibling())).toEqual({ + "server/info.txt": "server data", + "vscode-logs/proxy/active.log": "proxy", + }); + }); + + it.runIf(canTestFileMode)( + "preserves bundle permissions when replacing the original fails", + async () => { + const zipPath = await makeBundle(); + await fs.chmod(zipPath, 0o600); + vi.mocked(collectVsCodeDiagnostics).mockResolvedValue( + new Map([["vscode-logs/proxy/active.log", Buffer.from("proxy")]]), + ); + vi.mocked(renameWithRetry).mockRejectedValueOnce( + new Error("simulated rename failure"), + ); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect((await fs.stat(await findBundleSibling())).mode & 0o777).toBe( + 0o600, + ); + }, + ); + + it("leaves the original zip and existing sibling intact when the bundle cannot be read", async () => { + const zipPath = path.join(tmpDir, "coder-support-123.zip"); + await fs.writeFile(zipPath, "not a zip"); + const existingSiblingPath = path.join( + tmpDir, + "coder-support-123-vscode.zip", + ); + await fs.writeFile(existingSiblingPath, "existing"); + const beforeBytes = await fs.readFile(zipPath); + vi.mocked(collectVsCodeDiagnostics).mockResolvedValue( + new Map([["vscode-logs/proxy/active.log", Buffer.from("proxy")]]), + ); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + expect(await fs.readFile(existingSiblingPath, "utf8")).toBe("existing"); + expect( + (await fs.readdir(tmpDir)).filter((name) => + name.startsWith("coder-support-123-vscode-"), + ), + ).toEqual([]); + }); + + it("leaves the bundle unchanged when diagnostics collection fails", async () => { + const zipPath = await makeBundle(); + const beforeBytes = await fs.readFile(zipPath); + vi.mocked(collectVsCodeDiagnostics).mockRejectedValueOnce( + new Error("diagnostics failed"), + ); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + expect(logger.error).toHaveBeenCalledWith( + "Unexpected error appending VS Code diagnostics", + expect.any(Error), + ); + }); +}); diff --git a/test/unit/supportBundle/files.test.ts b/test/unit/supportBundle/files.test.ts new file mode 100644 index 000000000..e8c7fac07 --- /dev/null +++ b/test/unit/supportBundle/files.test.ts @@ -0,0 +1,123 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + addFiles, + collectDirFiles, + collectMatchingFiles, + isLogFile, + normalizeZipPath, + prefixFiles, + readLogFile, +} from "@/supportBundle/files"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +let tmpDir: string; +const logger = createMockLogger(); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "support-bundle-files-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function setAge(filePath: string, daysAgo: number): Promise { + const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + await fs.utimes(filePath, past, past); +} + +describe("support bundle file helpers", () => { + it("normalizes zip paths and identifies log files", () => { + expect(normalizeZipPath("a/b/c.log")).toBe("a/b/c.log"); + expect(isLogFile("Coder.log")).toBe(true); + expect(isLogFile("Coder.log.1")).toBe(true); + expect(isLogFile("Coder.log.12")).toBe(true); + expect(isLogFile("settings.json")).toBe(false); + expect(isLogFile("Coder.log.gz")).toBe(false); + }); + + it("prefixes and merges file maps", () => { + const target = new Map([["existing", Buffer.from("old")]]); + addFiles( + target, + prefixFiles("vscode-logs/proxy", new Map([["a.log", Buffer.from("a")]])), + ); + + expect([...target.keys()].sort()).toEqual([ + "existing", + "vscode-logs/proxy/a.log", + ]); + }); + + it("collects recent matching files and skips old files and subdirectories", async () => { + await fs.writeFile(path.join(tmpDir, "recent.log"), "recent"); + await fs.writeFile(path.join(tmpDir, "old.log"), "old"); + await fs.writeFile(path.join(tmpDir, "notes.txt"), "notes"); + await fs.mkdir(path.join(tmpDir, "subdir")); + await setAge(path.join(tmpDir, "old.log"), 5); + + const files = await collectDirFiles(tmpDir, logger, isLogFile); + + expect(files).toEqual(new Map([["recent.log", Buffer.from("recent")]])); + }); + + it("walks matching recent files recursively", async () => { + const nested = path.join(tmpDir, "window1", "output_logging_1"); + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile(path.join(nested, "1-Remote - SSH.log"), "ssh"); + + const files = await collectMatchingFiles( + tmpDir, + logger, + (_relativePath, fileName) => fileName.includes("Remote - SSH"), + ); + + expect(files).toMatchObject([ + { + data: Buffer.from("ssh"), + relativePath: path.join( + "window1", + "output_logging_1", + "1-Remote - SSH.log", + ), + }, + ]); + }); + + it("truncates oversized log files to the tail", async () => { + const filePath = path.join(tmpDir, "huge.log"); + const head = Buffer.alloc(60 * 1024 * 1024, "H"); + const tail = Buffer.from("TAIL_MARKER\n"); + await fs.writeFile(filePath, Buffer.concat([head, tail])); + + const result = await readLogFile(filePath, logger); + + expect(result?.data.byteLength).toBe(50 * 1024 * 1024); + expect( + Buffer.from(result?.data ?? new Uint8Array()) + .subarray(-tail.byteLength) + .toString(), + ).toBe("TAIL_MARKER\n"); + }); + + it.runIf(process.platform !== "win32")( + "does not follow symlinks when reading files", + async () => { + const outsideTarget = path.join(tmpDir, "outside.secret"); + await fs.writeFile(outsideTarget, "should-not-be-read"); + const logsDir = path.join(tmpDir, "logs"); + await fs.mkdir(logsDir); + await fs.symlink(outsideTarget, path.join(logsDir, "evil.log")); + await fs.writeFile(path.join(logsDir, "good.log"), "good"); + + const files = await collectDirFiles(logsDir, logger, isLogFile); + + expect(files).toEqual(new Map([["good.log", Buffer.from("good")]])); + }, + ); +}); diff --git a/test/unit/supportBundle/logFiles.test.ts b/test/unit/supportBundle/logFiles.test.ts new file mode 100644 index 000000000..888a91bd8 --- /dev/null +++ b/test/unit/supportBundle/logFiles.test.ts @@ -0,0 +1,485 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + collectSupportLogFiles, + collectWindowLogDirs, + resolveLogContext, +} from "@/supportBundle/logFiles"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +// chmod to 0o000 is a no-op as root and on Windows. +const canTestUnreadable = + process.getuid?.() !== 0 && process.platform !== "win32"; + +let tmpDir: string; +const logger = createMockLogger(); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "support-bundle-logs-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function setAge(filePath: string, daysAgo: number): Promise { + const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + await fs.utimes(filePath, past, past); +} + +async function collectTextFiles( + sources: Parameters[0], +): Promise> { + const files = await collectSupportLogFiles(sources, logger); + return Object.fromEntries( + [...files].map(([name, data]) => [name, Buffer.from(data).toString()]), + ); +} + +describe("collectSupportLogFiles", () => { + it("collects active proxy log under its real name alongside Coder SSH proxy logs", async () => { + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + const activeProxyLog = path.join(tmpDir, "custom-active.log"); + await fs.writeFile(activeProxyLog, "active"); + await fs.writeFile(path.join(proxyDir, "coder-ssh-recent.log"), "recent"); + await fs.writeFile(path.join(proxyDir, "coder-ssh-old.log"), "old"); + await fs.writeFile(path.join(proxyDir, "12345.log"), "pid-style"); + await fs.writeFile(path.join(proxyDir, "other.log"), "other"); + await fs.writeFile(path.join(proxyDir, "secret.env"), "secret"); + await fs.mkdir(path.join(proxyDir, "subdir")); + await setAge(path.join(proxyDir, "coder-ssh-old.log"), 5); + + await expect( + collectTextFiles({ + activeProxyLogPath: activeProxyLog, + proxyLogDir: proxyDir, + }), + ).resolves.toEqual({ + "vscode-logs/proxy/custom-active.log": "active", + "vscode-logs/proxy/coder-ssh-recent.log": "recent", + "vscode-logs/proxy/12345.log": "pid-style", + }); + }); + + it("does not double-bundle the active proxy log when it lives inside proxyLogDir", async () => { + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + const activeProxyLog = path.join(proxyDir, "coder-ssh-active.log"); + await fs.writeFile(activeProxyLog, "active"); + + const result = await collectTextFiles({ + activeProxyLogPath: activeProxyLog, + proxyLogDir: proxyDir, + }); + + expect(result).toEqual({ + "vscode-logs/proxy/coder-ssh-active.log": "active", + }); + }); + + it("collects recent extension logs (including rotated .log.N) from a non-canonical extension log directory", async () => { + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(extDir); + await fs.writeFile(path.join(extDir, "Coder-recent.log"), "recent"); + await fs.writeFile(path.join(extDir, "Coder-recent.log.1"), "rotated"); + await fs.writeFile(path.join(extDir, "Coder-old.log"), "old"); + await fs.writeFile(path.join(extDir, "notes.txt"), "notes"); + await fs.mkdir(path.join(extDir, "subdir")); + await setAge(path.join(extDir, "Coder-old.log"), 5); + + await expect( + collectTextFiles({ extensionLogDir: extDir }), + ).resolves.toEqual({ + "vscode-logs/extension/Coder-recent.log": "recent", + "vscode-logs/extension/Coder-recent.log.1": "rotated", + }); + }); + + it("collects extension and Remote-SSH logs across recent VS Code sessions", async () => { + const logsRoot = path.join(tmpDir, "logs"); + const currentSession = "20240103T000000"; + const previousSession = "20240102T000000"; + const oldSession = "20231231T000000"; + const window = "window1"; + + const currentExtDir = path.join( + logsRoot, + currentSession, + window, + "exthost", + "coder.coder-remote", + ); + const previousExtDir = path.join( + logsRoot, + previousSession, + window, + "exthost", + "coder.coder-remote", + ); + const oldExtDir = path.join( + logsRoot, + oldSession, + window, + "exthost", + "coder.coder-remote", + ); + await fs.mkdir(currentExtDir, { recursive: true }); + await fs.mkdir(previousExtDir, { recursive: true }); + await fs.mkdir(oldExtDir, { recursive: true }); + await fs.writeFile(path.join(currentExtDir, "Coder.log"), "current"); + await fs.writeFile(path.join(previousExtDir, "Coder.log"), "previous"); + await fs.writeFile(path.join(oldExtDir, "Coder.log"), "old"); + await setAge(path.join(oldExtDir, "Coder.log"), 5); + + const currentRemoteDir = path.join( + logsRoot, + currentSession, + window, + "output_logging_current", + ); + const previousRemoteDir = path.join( + logsRoot, + previousSession, + window, + "output_logging_previous", + ); + const oldRemoteDir = path.join( + logsRoot, + oldSession, + window, + "output_logging_old", + ); + await fs.mkdir(currentRemoteDir, { recursive: true }); + await fs.mkdir(previousRemoteDir, { recursive: true }); + await fs.mkdir(oldRemoteDir, { recursive: true }); + await fs.writeFile( + path.join(currentRemoteDir, "1-Remote - SSH.log"), + "current ssh", + ); + await fs.writeFile( + path.join(previousRemoteDir, "1-Remote - SSH.log"), + "previous ssh", + ); + await fs.writeFile( + path.join(oldRemoteDir, "1-Remote - SSH.log"), + "old ssh", + ); + await setAge(path.join(oldRemoteDir, "1-Remote - SSH.log"), 5); + const future = new Date(Date.now() + 60_000); + await fs.utimes( + path.join(previousRemoteDir, "1-Remote - SSH.log"), + future, + future, + ); + + await expect( + collectTextFiles({ extensionLogDir: currentExtDir }), + ).resolves.toEqual({ + [`vscode-logs/extension/${currentSession}/${window}/Coder.log`]: + "current", + [`vscode-logs/extension/${previousSession}/${window}/Coder.log`]: + "previous", + "vscode-logs/remote-ssh/1-Remote - SSH.log": "current ssh", + [`vscode-logs/remote-ssh/${currentSession}/${window}/output_logging_current/1-Remote - SSH.log`]: + "current ssh", + [`vscode-logs/remote-ssh/${previousSession}/${window}/output_logging_previous/1-Remote - SSH.log`]: + "previous ssh", + }); + }); + + it("collects Remote-SSH logs only for windows with Coder extension logs", async () => { + const logsRoot = path.join(tmpDir, "logs"); + const currentExtDir = path.join( + logsRoot, + "20240101T000000", + "window1", + "exthost", + "coder.coder-remote", + ); + await fs.mkdir(currentExtDir, { recursive: true }); + await fs.writeFile(path.join(currentExtDir, "Coder.log"), "coder"); + const relatedRemoteDir = path.join( + logsRoot, + "20240101T000000", + "window1", + "output_logging_1", + ); + const unrelatedRemoteDir = path.join( + logsRoot, + "20240101T000000", + "window2", + "output_logging_2", + ); + await fs.mkdir(relatedRemoteDir, { recursive: true }); + await fs.mkdir(unrelatedRemoteDir, { recursive: true }); + await fs.writeFile( + path.join(relatedRemoteDir, "1-Remote - SSH.log"), + "related", + ); + await fs.writeFile( + path.join(unrelatedRemoteDir, "1-Remote - SSH.log"), + "unrelated", + ); + + const files = await collectTextFiles({ extensionLogDir: currentExtDir }); + + expect(files).toMatchObject({ + "vscode-logs/remote-ssh/1-Remote - SSH.log": "related", + "vscode-logs/remote-ssh/20240101T000000/window1/output_logging_1/1-Remote - SSH.log": + "related", + }); + expect( + files[ + "vscode-logs/remote-ssh/20240101T000000/window2/output_logging_2/1-Remote - SSH.log" + ], + ).toBeUndefined(); + }); + + it("collects Remote-SSH logs from the host extension's exthost dir even when filenames lack 'Remote - SSH'", async () => { + const logsRoot = path.join(tmpDir, "logs"); + const currentExtDir = path.join( + logsRoot, + "20240101T000000", + "window1", + "exthost", + "coder.coder-remote", + ); + await fs.mkdir(currentExtDir, { recursive: true }); + await fs.writeFile(path.join(currentExtDir, "Coder.log"), "coder"); + + const remoteSshExtDir = path.join( + logsRoot, + "20240101T000000", + "window1", + "exthost", + "ms-vscode-remote.remote-ssh", + ); + await fs.mkdir(remoteSshExtDir, { recursive: true }); + await fs.writeFile(path.join(remoteSshExtDir, "1.log"), "ext-host-log"); + + const files = await collectTextFiles({ extensionLogDir: currentExtDir }); + + expect(files).toMatchObject({ + "vscode-logs/remote-ssh/20240101T000000/window1/exthost/ms-vscode-remote.remote-ssh/1.log": + "ext-host-log", + }); + }); + + it("does not promote a stale log from another window when the current window has no Remote-SSH logs", async () => { + const logsRoot = path.join(tmpDir, "logs"); + const currentSession = "20240103T000000"; + const otherSession = "20240102T000000"; + + const currentExtDir = path.join( + logsRoot, + currentSession, + "window1", + "exthost", + "coder.coder-remote", + ); + const otherExtDir = path.join( + logsRoot, + otherSession, + "window1", + "exthost", + "coder.coder-remote", + ); + await fs.mkdir(currentExtDir, { recursive: true }); + await fs.mkdir(otherExtDir, { recursive: true }); + await fs.writeFile(path.join(currentExtDir, "Coder.log"), "current"); + await fs.writeFile(path.join(otherExtDir, "Coder.log"), "other"); + + const otherSshDir = path.join( + logsRoot, + otherSession, + "window1", + "output_logging_1", + ); + await fs.mkdir(otherSshDir, { recursive: true }); + await fs.writeFile( + path.join(otherSshDir, "1-Remote - SSH.log"), + "stale ssh", + ); + + const files = await collectTextFiles({ extensionLogDir: currentExtDir }); + + expect(files["vscode-logs/remote-ssh/1-Remote - SSH.log"]).toBeUndefined(); + expect( + files[ + `vscode-logs/remote-ssh/${otherSession}/window1/output_logging_1/1-Remote - SSH.log` + ], + ).toBe("stale ssh"); + }); + + it("falls back to scanning the assumed window dir when the layout is non-canonical", async () => { + const extDir = path.join(tmpDir, "ext"); + const siblingSshDir = path.join(tmpDir, "output_logging_1"); + await fs.mkdir(extDir); + await fs.mkdir(siblingSshDir); + await fs.writeFile(path.join(siblingSshDir, "1-Remote - SSH.log"), "ssh"); + await fs.writeFile(path.join(extDir, "Coder.log"), "coder"); + + const files = await collectTextFiles({ extensionLogDir: extDir }); + + expect(files).toMatchObject({ + "vscode-logs/extension/Coder.log": "coder", + "vscode-logs/remote-ssh/output_logging_1/1-Remote - SSH.log": "ssh", + }); + }); + + it("resolves a forked Coder extension id via the same layout", async () => { + const logsRoot = path.join(tmpDir, "logs"); + const currentExtDir = path.join( + logsRoot, + "20240101T000000", + "window1", + "exthost", + "mycompany.coder-remote", + ); + await fs.mkdir(currentExtDir, { recursive: true }); + await fs.writeFile(path.join(currentExtDir, "Coder.log"), "coder"); + + const previousExtDir = path.join( + logsRoot, + "20240102T000000", + "window1", + "exthost", + "mycompany.coder-remote", + ); + await fs.mkdir(previousExtDir, { recursive: true }); + await fs.writeFile(path.join(previousExtDir, "Coder.log"), "previous"); + + const files = await collectTextFiles({ extensionLogDir: currentExtDir }); + + expect(files).toMatchObject({ + "vscode-logs/extension/20240101T000000/window1/Coder.log": "coder", + "vscode-logs/extension/20240102T000000/window1/Coder.log": "previous", + }); + }); + + it.runIf(canTestUnreadable)( + "skips missing or unreadable sources and includes readable files", + async () => { + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "coder-ssh-good.log"), "ok"); + const badLog = path.join(proxyDir, "coder-ssh-bad.log"); + await fs.writeFile(badLog, "secret"); + await fs.chmod(badLog, 0o000); + + try { + await expect( + collectTextFiles({ + activeProxyLogPath: path.join(tmpDir, "nonexistent.log"), + proxyLogDir: proxyDir, + extensionLogDir: path.join(tmpDir, "no-such-dir"), + }), + ).resolves.toEqual({ + "vscode-logs/proxy/coder-ssh-good.log": "ok", + }); + } finally { + await fs.chmod(badLog, 0o644); + } + }, + ); +}); + +describe("resolveLogContext", () => { + it("resolves VS Code session log layout", () => { + const extensionLogDir = path.join( + tmpDir, + "20240101T000000", + "window1", + "exthost", + "coder.coder-remote", + ); + + expect(resolveLogContext(extensionLogDir)).toEqual({ + currentWindowPath: path.join(tmpDir, "20240101T000000", "window1"), + logsRoot: tmpDir, + }); + }); + + it("resolves flat window log layout", () => { + const extensionLogDir = path.join( + tmpDir, + "window1", + "exthost", + "coder.coder-remote", + ); + + expect(resolveLogContext(extensionLogDir)).toEqual({ + currentWindowPath: path.join(tmpDir, "window1"), + logsRoot: tmpDir, + }); + }); + + it("ignores paths outside the Coder extension log layout", () => { + expect( + resolveLogContext(path.join(tmpDir, "window1", "other")), + ).toBeUndefined(); + }); + + it("accepts a forked Coder extension id provided the layout matches", () => { + const extensionLogDir = path.join( + tmpDir, + "20240101T000000", + "window1", + "exthost", + "mycompany.coder-remote", + ); + + expect(resolveLogContext(extensionLogDir)).toEqual({ + currentWindowPath: path.join(tmpDir, "20240101T000000", "window1"), + logsRoot: tmpDir, + }); + }); + + it("does not treat a non-canonical session-suffix name as a session root", () => { + const extensionLogDir = path.join( + tmpDir, + "20240101T000000-foo", + "window1", + "exthost", + "coder.coder-remote", + ); + + expect(resolveLogContext(extensionLogDir)).toEqual({ + currentWindowPath: path.join(tmpDir, "20240101T000000-foo", "window1"), + logsRoot: path.join(tmpDir, "20240101T000000-foo"), + }); + }); +}); + +describe("collectWindowLogDirs", () => { + it("finds and sorts session and flat window directories", async () => { + await fs.mkdir(path.join(tmpDir, "20240102T000000", "window2"), { + recursive: true, + }); + await fs.mkdir(path.join(tmpDir, "20240101T000000", "window1"), { + recursive: true, + }); + await fs.mkdir(path.join(tmpDir, "window3"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "not-a-window.log"), "ignore"); + + await expect(collectWindowLogDirs(tmpDir, logger)).resolves.toEqual([ + { + relativePath: "20240101T000000/window1", + windowPath: path.join(tmpDir, "20240101T000000", "window1"), + }, + { + relativePath: "20240102T000000/window2", + windowPath: path.join(tmpDir, "20240102T000000", "window2"), + }, + { + relativePath: "window3", + windowPath: path.join(tmpDir, "window3"), + }, + ]); + }); +}); diff --git a/test/unit/supportBundle/settings.test.ts b/test/unit/supportBundle/settings.test.ts new file mode 100644 index 000000000..76fd0ccac --- /dev/null +++ b/test/unit/supportBundle/settings.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { collectSettingsFile } from "@/supportBundle/settings"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +const logger = createMockLogger(); + +beforeEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReset(); + vi.mocked(logger.warn).mockClear(); +}); + +function setConfiguration( + values: Record, + inspections: Record>, +): void { + const config: Partial = { + get: (key: string): T | undefined => values[key] as T | undefined, + inspect: (key: string) => { + const inspection = inspections[key]; + return inspection ? { key, ...inspection } : undefined; + }, + }; + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue( + config as vscode.WorkspaceConfiguration, + ); +} + +describe("collectSettingsFile", () => { + it("returns undefined when there are no supported settings", () => { + setConfiguration({}, {}); + + expect(collectSettingsFile(logger)).toBeUndefined(); + }); + + it("redacts sensitive Coder settings while preserving safe ones", () => { + setConfiguration( + { + "coder.headerCommand": "echo DO_NOT_LEAK_SECRET", + "coder.sshFlags": ["--flag", "DO_NOT_LEAK_SECRET"], + "coder.tlsCertFile": "/etc/ssl/DO_NOT_LEAK_SECRET.pem", + "coder.defaultUrl": "https://internal.DO_NOT_LEAK_SECRET", + "coder.insecure": true, + "coder.httpClientLogLevel": "debug", + "coder.binarySource": "", + }, + { + "coder.headerCommand": { + defaultValue: "", + globalValue: "echo DO_NOT_LEAK_SECRET", + }, + "coder.sshFlags": { + defaultValue: [], + globalValue: ["--flag", "DO_NOT_LEAK_SECRET"], + }, + "coder.tlsCertFile": { + defaultValue: "", + globalValue: "/etc/ssl/DO_NOT_LEAK_SECRET.pem", + }, + "coder.defaultUrl": { + defaultValue: "", + globalValue: "https://internal.DO_NOT_LEAK_SECRET", + }, + "coder.insecure": { defaultValue: false, globalValue: true }, + "coder.httpClientLogLevel": { + defaultValue: "info", + globalValue: "debug", + }, + "coder.binarySource": { defaultValue: "", globalValue: "" }, + }, + ); + + const raw = Buffer.from( + collectSettingsFile(logger) ?? new Uint8Array(), + ).toString(); + const settings = JSON.parse(raw) as Record>; + + expect(raw).not.toContain("DO_NOT_LEAK_SECRET"); + expect(settings["coder.headerCommand"]).toEqual({ + defaultValue: "", + effective: "", + globalValue: "", + key: "coder.headerCommand", + }); + expect(settings["coder.sshFlags"]?.effective).toBe(""); + expect(settings["coder.tlsCertFile"]?.effective).toBe(""); + expect(settings["coder.defaultUrl"]?.effective).toBe(""); + expect(settings["coder.binarySource"]?.effective).toBe(""); + // Non-sensitive settings pass through verbatim. + expect(settings["coder.insecure"]?.effective).toBe(true); + expect(settings["coder.httpClientLogLevel"]?.effective).toBe("debug"); + }); + + it("collects allowlisted Remote-SSH settings", () => { + setConfiguration( + { + "remote.SSH.connectTimeout": 1800, + "remote.autoForwardPorts": true, + }, + { + "remote.SSH.connectTimeout": { defaultValue: 60, globalValue: 1800 }, + "remote.autoForwardPorts": { + defaultValue: false, + workspaceValue: true, + }, + }, + ); + + const settings = JSON.parse( + Buffer.from(collectSettingsFile(logger) ?? new Uint8Array()).toString(), + ) as Record>; + + expect(settings["remote.SSH.connectTimeout"]?.globalValue).toBe(1800); + expect(settings["remote.autoForwardPorts"]?.workspaceValue).toBe(true); + }); + + it("preserves languageIds metadata on redacted settings", () => { + setConfiguration( + { "coder.headerCommand": "echo secret" }, + { + "coder.headerCommand": { + globalValue: "echo secret", + languageIds: ["typescript"], + }, + }, + ); + + const settings = JSON.parse( + Buffer.from(collectSettingsFile(logger) ?? new Uint8Array()).toString(), + ) as Record>; + + expect(settings["coder.headerCommand"]?.languageIds).toEqual([ + "typescript", + ]); + expect(settings["coder.headerCommand"]?.globalValue).toBe(""); + }); + + it("warns and returns undefined when settings collection fails", () => { + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("settings failed"); + }); + + expect(collectSettingsFile(logger)).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + "Could not collect VS Code settings", + expect.any(Error), + ); + }); +});