diff --git a/src/auth-paths.ts b/src/auth-paths.ts new file mode 100644 index 00000000..ae036f02 --- /dev/null +++ b/src/auth-paths.ts @@ -0,0 +1,66 @@ +/** + * @file Pure path derivation for the auth settings file, split out of `auth.ts` + * so it can be unit-tested without pulling in the VSCode runtime (`auth.ts` + * imports `vscode`, which only resolves inside the extension host). This file + * imports only `node:path`. + */ + +import { mkdirSync } from 'node:fs' +import path from 'node:path' + +/** + * Ensure `dirPath` exists, creating it (and any missing parents) if needed. + * + * This is the SURF-111 fix: the settings directory does not exist until the + * user first logs in, and pointing a VSCode file-system watcher at a missing + * directory makes VSCode repeatedly log that it is watching a non-existent + * folder. Creating it first keeps the watcher's base present. + * + * `mkdirSync` with `recursive: true` is idempotent — it is a no-op when the + * directory already exists. Any error (for example a read-only filesystem) is + * swallowed so this can never break extension activation. Uses `node:fs` + * directly (like the cache directory in the scores manager) so the behavior is + * unit-testable against a real temporary directory; for the local data-home + * path this is equivalent to `vscode.workspace.fs.createDirectory`. + */ +export function ensureDirectoryExists(dirPath: string): void { + try { + mkdirSync(dirPath, { recursive: true }) + } catch {} +} + +/** + * Resolve the per-user data directory Socket stores its settings file under. + * This is the base whose `socket` subdirectory the extension both writes to (on + * login) and watches — the directory that was missing on a fresh install and + * produced the "searching for non-existent folder" watcher error (SURF-111). + * + * On Windows the value comes from `%LOCALAPPDATA%`, and its absence is fatal + * (there is no sensible fallback). Elsewhere it comes from `$XDG_DATA_HOME`, + * falling back to `~/Library/Application Support` on macOS and `~/.local/share` + * on other platforms. + * + * Pure: takes the platform, environment, and home directory as arguments so the + * cross-platform path derivation can be unit-tested without touching the real + * process. + */ +export function resolveDataHome( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + homedir: string, +): string { + const dataHome = + platform === 'win32' ? env['LOCALAPPDATA'] : env['XDG_DATA_HOME'] + if (dataHome) { + return dataHome + } + if (platform === 'win32') { + throw new Error('missing %LOCALAPPDATA%') + } + return path.join( + homedir, + ...(platform === 'darwin' + ? ['Library', 'Application Support'] + : ['.local', 'share']), + ) +} diff --git a/src/auth.ts b/src/auth.ts index 96d0ff7d..6b953360 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,6 +8,7 @@ import { once } from 'node:events' import type { IncomingMessage } from 'node:http' import { text } from 'node:stream/consumers' import crypto from 'node:crypto' +import { ensureDirectoryExists, resolveDataHome } from './auth-paths' export type APIConfig = { apiKey: string } @@ -34,23 +35,7 @@ export async function activate( ) { //#region file path/watching // responsible for watching files to know when to sync from disk - let dataHome = - process.platform === 'win32' - ? process.env['LOCALAPPDATA'] - : process.env['XDG_DATA_HOME'] - - if (!dataHome) { - if (process.platform === 'win32') { - throw new Error('missing %LOCALAPPDATA%') - } - const home = os.homedir() - dataHome = path.join( - home, - ...(process.platform === 'darwin' - ? ['Library', 'Application Support'] - : ['.local', 'share']), - ) - } + const dataHome = resolveDataHome(process.platform, process.env, os.homedir()) const pleaseLoginStatusBar = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, 100, @@ -75,6 +60,12 @@ export async function activate( const diskSessionsChanges = new vscode.EventEmitter() + // The settings directory (e.g. ~/.local/share/socket, %LOCALAPPDATA%\socket) + // does not exist until the user first logs in and we persist a token. + // Pointing a file-system watcher at a missing base directory makes VSCode + // repeatedly log that it is watching a non-existent folder (SURF-111). Ensure + // the directory exists first (idempotent, and never throws). + ensureDirectoryExists(path.dirname(settingsPath)) const watcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern( path.dirname(settingsPath), diff --git a/test/auth-paths.test.mts b/test/auth-paths.test.mts new file mode 100644 index 00000000..d3dfb350 --- /dev/null +++ b/test/auth-paths.test.mts @@ -0,0 +1,104 @@ +/** + * @file Unit tests for src/auth-paths.ts — the SURF-111 fix. Before the fix, + * the extension pointed a file watcher at the settings directory, which does + * not exist until first login, so VSCode logged that it was "searching for a + * non-existent folder". ensureDirectoryExists now creates that directory + * first, and resolveDataHome computes which directory that is per platform. + */ + +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { ensureDirectoryExists, resolveDataHome } from '../src/auth-paths' + +describe('ensureDirectoryExists', () => { + let tmpRoot: string + + beforeEach(() => { + tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'socket-auth-paths-')) + }) + + afterEach(async () => { + await safeDelete(tmpRoot) + }) + + test('creates the directory (and missing parents) when absent', () => { + // The real SURF-111 shape: /socket where neither exists yet. + const dir = path.join(tmpRoot, 'dataHome', 'socket') + expect(existsSync(dir)).toBe(false) + ensureDirectoryExists(dir) + expect(existsSync(dir)).toBe(true) + }) + + test('is a no-op and does not throw when the directory already exists', () => { + const dir = path.join(tmpRoot, 'already-here') + ensureDirectoryExists(dir) + // Put a file inside, then call again: the directory and its contents must + // survive (recursive mkdir does not clear an existing directory). + const marker = path.join(dir, 'marker.txt') + writeFileSync(marker, 'keep me') + expect(() => ensureDirectoryExists(dir)).not.toThrow() + expect(existsSync(dir)).toBe(true) + expect(existsSync(marker)).toBe(true) + }) + + test('does not throw when creation fails (path under a file)', () => { + // Using a regular file as a parent makes mkdir fail (ENOTDIR); the helper + // must swallow it so activation can never be broken by the filesystem. + const filePath = path.join(tmpRoot, 'a-file') + writeFileSync(filePath, 'not a directory') + const doomed = path.join(filePath, 'child') + expect(() => ensureDirectoryExists(doomed)).not.toThrow() + expect(existsSync(doomed)).toBe(false) + }) +}) + +describe('resolveDataHome', () => { + const HOME = '/home/tester' + + test('uses %LOCALAPPDATA% on Windows', () => { + expect( + resolveDataHome( + 'win32', + { LOCALAPPDATA: 'C:\\Users\\t\\AppData\\Local' }, + HOME, + ), + ).toBe('C:\\Users\\t\\AppData\\Local') + }) + + test('throws on Windows when %LOCALAPPDATA% is missing', () => { + expect(() => resolveDataHome('win32', {}, HOME)).toThrow('%LOCALAPPDATA%') + }) + + test('uses $XDG_DATA_HOME when set on non-Windows', () => { + expect( + resolveDataHome('linux', { XDG_DATA_HOME: '/custom/xdg' }, HOME), + ).toBe('/custom/xdg') + }) + + test('falls back to ~/Library/Application Support on macOS', () => { + expect(resolveDataHome('darwin', {}, HOME)).toBe( + path.join(HOME, 'Library', 'Application Support'), + ) + }) + + test('falls back to ~/.local/share on other platforms', () => { + expect(resolveDataHome('linux', {}, HOME)).toBe( + path.join(HOME, '.local', 'share'), + ) + }) + + test('ignores XDG_DATA_HOME on Windows (reads LOCALAPPDATA)', () => { + expect( + resolveDataHome( + 'win32', + { LOCALAPPDATA: 'C:\\local', XDG_DATA_HOME: '/ignored' }, + HOME, + ), + ).toBe('C:\\local') + }) +})