From e6bce3630907f85a19ea812ab872cd4d27c0d4f3 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 29 May 2026 01:11:26 -0400 Subject: [PATCH 1/5] feat(http-request): gzip/br response decompression, basicAuthHeader, pattern-based redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three centralizations so fleet callers stop hand-rolling HTTP concerns: - Accept-Encoding: gzip, br on buffered requests + transparent decompression in response-reader by Content-Encoding. Node's http client neither negotiates nor decompresses, so a compressed Socket API response previously reached callers as raw deflated bytes. Streamed requests (stream: true, e.g. httpDownload) deliberately omit Accept-Encoding — they pipe raw to disk and would otherwise land compressed and fail checksum. Callers can override (e.g. 'identity'). - basicAuthHeader(token) in headers.ts — the Socket API Basic-auth shape (token as username, empty password). socket-sdk-js hand-rolls this today. - sanitizeHeaders now redacts by name SHAPE (isSensitiveHeaderName regex: auth|cookie|credential|key|password|secret|token) instead of a fixed list, so custom token headers (x-amz-security-token, api-key, …) are covered without enumeration. Same reasoning as 'a denylist is itself a leak'. --- src/http-request/headers.ts | 63 ++++++++++++++---- src/http-request/request-attempt.ts | 7 ++ src/http-request/response-reader.ts | 43 +++++++++++- .../isolated/http-request-advanced-1.test.mts | 27 ++++++++ test/unit/http-request/headers.test.mts | 66 +++++++++++++++++++ .../http-request/response-reader.test.mts | 59 +++++++++++++++++ 6 files changed, 250 insertions(+), 15 deletions(-) diff --git a/src/http-request/headers.ts b/src/http-request/headers.ts index 2ebcdda35..b81ca7fcf 100644 --- a/src/http-request/headers.ts +++ b/src/http-request/headers.ts @@ -14,13 +14,57 @@ import { ArrayIsArray } from '../primordials/array' import { DateCtor, DateNow } from '../primordials/date' -import { SetCtor } from '../primordials/map-set' +import { btoa } from '../primordials/globals' import { NumberIsNaN } from '../primordials/number' import { ObjectKeys } from '../primordials/object' const RETRY_AFTER_INT_RE = /^\d+$/ +/** + * Build an HTTP Basic `Authorization` header value from a Socket API token. + * + * The Socket API uses the token as the username with an empty password, so the + * credential pair is `:`. Centralized here so every fleet caller emits + * the identical shape instead of hand-rolling `btoa(\`${token}:`)`. + * + * @example + * ;```ts + * const headers = { Authorization: basicAuthHeader(apiToken) } + * // { Authorization: 'Basic c2t0X3h4eHg6' } + * ``` + * + * @param token - The Socket API token (used as the Basic-auth username). + * + * @returns The `Authorization` header value, e.g. `Basic `. + */ +/*@__NO_SIDE_EFFECTS__*/ +export function basicAuthHeader(token: string): string { + return `Basic ${btoa(`${token}:`)}` +} + +// Match credential-bearing header names by shape rather than an enumerated +// list. A fixed list reads as complete while silently missing real headers +// (x-amz-security-token, api-key, x-functions-key, …); a name pattern catches +// the family. Same reasoning as the fleet's "a denylist is itself a leak" — +// don't try to name every secret, recognize the shape. The standard auth / +// cookie / proxy headers all contain one of these tokens, so they stay covered. +const SENSITIVE_HEADER_NAME_RE = + /auth|cookie|credential|key|password|secret|token/i + +/** + * Whether a header name looks credential-bearing and should be redacted from + * logs and telemetry. Case-insensitive substring match on the name only — the + * value is never inspected. + * + * @param name - The header name (e.g. `Authorization`, `x-api-key`). + * + * @returns `true` when the value should be replaced with `[REDACTED]`. + */ +export function isSensitiveHeaderName(name: string): boolean { + return SENSITIVE_HEADER_NAME_RE.test(name) +} + /** * Parse a `Retry-After` HTTP header value into milliseconds. * @@ -76,9 +120,10 @@ export function parseRetryAfterHeader( /** * Redact sensitive HTTP headers for safe logging and telemetry. * - * Replaces values of sensitive headers (Authorization, Cookie, etc.) with - * `[REDACTED]`. Non-sensitive headers are passed through unchanged. Array - * values are joined with `', '`. + * Replaces values of credential-bearing headers with `[REDACTED]`, matching the + * header name by shape (see `isSensitiveHeaderName`) so custom token headers + * are covered without an enumerated list. Non-sensitive headers pass through + * unchanged. Array values are joined with `', '`. * * @example * ;```ts @@ -99,20 +144,12 @@ export function sanitizeHeaders( if (!headers) { return {} } - const sensitiveHeaders = new SetCtor([ - 'authorization', - 'cookie', - 'proxy-authorization', - 'proxy-authenticate', - 'set-cookie', - 'www-authenticate', - ]) const result: Record = { __proto__: null, } as unknown as Record for (const key of ObjectKeys(headers)) { const value = headers[key] - if (sensitiveHeaders.has(key.toLowerCase())) { + if (isSensitiveHeaderName(key)) { result[key] = '[REDACTED]' } else if (ArrayIsArray(value)) { result[key] = value.join(', ') diff --git a/src/http-request/request-attempt.ts b/src/http-request/request-attempt.ts index 8a234f98c..8a8b665f6 100644 --- a/src/http-request/request-attempt.ts +++ b/src/http-request/request-attempt.ts @@ -65,6 +65,13 @@ export async function httpRequestAttempt( : undefined const mergedHeaders = { + // Advertise only the encodings response-reader can decompress, and ONLY for + // buffered responses. Streamed responses (stream: true, e.g. httpDownload) + // are piped to disk raw — response-reader never runs — so a compressed body + // would land on disk still deflated and fail checksum. Node's http client + // does not negotiate or decompress on its own. Caller headers win, so a + // caller can override (e.g. 'identity') if it wants raw bytes. + ...(stream ? undefined : { 'Accept-Encoding': 'gzip, br' }), 'User-Agent': getSocketCallerUserAgent(), ...streamHeaders, ...headers, diff --git a/src/http-request/response-reader.ts b/src/http-request/response-reader.ts index 7bb248f2f..bbc41a877 100644 --- a/src/http-request/response-reader.ts +++ b/src/http-request/response-reader.ts @@ -3,15 +3,51 @@ * out of `http-request/request.ts` for size hygiene. Useful when a caller * already has an `IncomingMessage` from code that bypasses `httpRequest()` * (e.g., multipart uploads via `http.request()` directly, or third-party HTTP - * libraries) and wants the same fetch-like body accessors. + * libraries) and wants the same fetch-like body accessors. The body is + * transparently decompressed when the response carries a `Content-Encoding` + * of `gzip` or `br` — the two encodings `httpRequest` advertises via + * `Accept-Encoding`. Node's http client does not decompress on its own, so + * without this step a compressed Socket API response would reach callers as + * raw deflated bytes and fail JSON parsing. */ +import { decompressBrotli } from '../compression/brotli' +import { decompressGzip } from '../compression/gzip' import { BufferConcat } from '../primordials/buffer' import { JSONParse } from '../primordials/json' import type { IncomingResponse } from './request-types' import type { HttpResponse } from './response-types' +/** + * Decompress a response body per its `Content-Encoding`. Returns the input + * unchanged for `identity` or any unrecognized/absent encoding — we only + * decompress what `httpRequest` advertised support for (`gzip`, `br`). + */ +export async function decodeBody( + body: Buffer, + contentEncoding: string | string[] | undefined, +): Promise { + if (!contentEncoding || body.length === 0) { + return body + } + // A comma-separated list applies encodings in order; the last applied is the + // first to undo. In practice servers send a single token — handle the common + // case and bail (return as-is) on anything layered or unrecognized. + const encoding = ( + Array.isArray(contentEncoding) ? contentEncoding[0]! : contentEncoding + ) + .trim() + .toLowerCase() + if (encoding === 'gzip') { + return await decompressGzip(body) + } + if (encoding === 'br') { + return await decompressBrotli(body) + } + return body +} + /** * Read and buffer a client-side IncomingResponse into an HttpResponse. * @@ -34,7 +70,10 @@ export async function readIncomingResponse( for await (const chunk of msg) { chunks.push(chunk as Buffer) } - const body = BufferConcat!(chunks) + const body = await decodeBody( + BufferConcat!(chunks), + msg.headers['content-encoding'], + ) const status = msg.statusCode ?? 0 const statusText = msg.statusMessage ?? '' return { diff --git a/test/isolated/http-request-advanced-1.test.mts b/test/isolated/http-request-advanced-1.test.mts index 4cf1b1398..1754b40b2 100644 --- a/test/isolated/http-request-advanced-1.test.mts +++ b/test/isolated/http-request-advanced-1.test.mts @@ -240,6 +240,33 @@ describe('http-request', () => { getSocketCallerUserAgent(), ) expect(requestInfos[0]!.headers['X-Custom']).toBe('test-value') + // Buffered requests advertise the encodings response-reader can decode. + expect(requestInfos[0]!.headers['Accept-Encoding']).toBe('gzip, br') + }) + + it('omits Accept-Encoding on streamed requests (piped raw to disk)', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + const response = await httpRequest(`${fixture.baseUrl}/json`, { + stream: true, + hooks: { + onRequest: info => requestInfos.push(info), + }, + }) + // Drain the stream so the socket closes cleanly. + response.rawResponse?.resume() + expect(requestInfos).toHaveLength(1) + expect(requestInfos[0]!.headers['Accept-Encoding']).toBeUndefined() + }) + + it('lets a caller override Accept-Encoding', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + await httpRequest(`${fixture.baseUrl}/json`, { + headers: { 'Accept-Encoding': 'identity' }, + hooks: { + onRequest: info => requestInfos.push(info), + }, + }) + expect(requestInfos[0]!.headers['Accept-Encoding']).toBe('identity') }) it('should call onResponse with status, headers, and duration', async () => { diff --git a/test/unit/http-request/headers.test.mts b/test/unit/http-request/headers.test.mts index 6fe9068f7..39a97b84e 100644 --- a/test/unit/http-request/headers.test.mts +++ b/test/unit/http-request/headers.test.mts @@ -1,10 +1,56 @@ import { describe, expect, test } from 'vitest' import { + basicAuthHeader, + isSensitiveHeaderName, parseRetryAfterHeader, sanitizeHeaders, } from '../../../src/http-request/headers' +describe.sequential('http-request/headers — isSensitiveHeaderName', () => { + test('matches credential header families (case-insensitive)', () => { + for (const name of [ + 'Authorization', + 'cookie', + 'Set-Cookie', + 'proxy-authorization', + 'www-authenticate', + 'x-api-key', + 'API-KEY', + 'x-auth-token', + 'x-amz-security-token', + 'x-secret', + 'db-password', + ]) { + expect(isSensitiveHeaderName(name)).toBe(true) + } + }) + + test('does not match benign headers', () => { + for (const name of [ + 'content-type', + 'accept', + 'user-agent', + 'x-request-id', + 'retry-after', + ]) { + expect(isSensitiveHeaderName(name)).toBe(false) + } + }) +}) + +describe.sequential('http-request/headers — basicAuthHeader', () => { + test('builds Basic header with token as username and empty password', () => { + // 'tok:' base64 is 'dG9rOg==' + expect(basicAuthHeader('tok')).toBe('Basic dG9rOg==') + }) + + test('handles an empty token', () => { + // ':' base64 is 'Og==' + expect(basicAuthHeader('')).toBe('Basic Og==') + }) +}) + describe.sequential('http-request/headers — parseRetryAfterHeader', () => { test('returns undefined for undefined input', () => { expect(parseRetryAfterHeader(undefined)).toBeUndefined() @@ -78,6 +124,26 @@ describe.sequential('http-request/headers — sanitizeHeaders', () => { }) }) + test('redacts custom credential headers by name shape, not a fixed list', () => { + expect( + sanitizeHeaders({ + 'x-api-key': 'sk_live_xxx', + 'x-auth-token': 'tok_yyy', + 'x-amz-security-token': 'amz_zzz', + 'api-key': 'plain_key', + 'content-type': 'application/json', + 'x-request-id': 'req-123', + }), + ).toEqual({ + 'x-api-key': '[REDACTED]', + 'x-auth-token': '[REDACTED]', + 'x-amz-security-token': '[REDACTED]', + 'api-key': '[REDACTED]', + 'content-type': 'application/json', + 'x-request-id': 'req-123', + }) + }) + test('redacts cookie / set-cookie / proxy variants', () => { expect( sanitizeHeaders({ diff --git a/test/unit/http-request/response-reader.test.mts b/test/unit/http-request/response-reader.test.mts index 0881ff60b..66a5f1ac5 100644 --- a/test/unit/http-request/response-reader.test.mts +++ b/test/unit/http-request/response-reader.test.mts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest' +import { compressBrotli } from '../../../src/compression/brotli' +import { compressGzip } from '../../../src/compression/gzip' import { readIncomingResponse } from '../../../src/http-request/response-reader' import type { IncomingResponse } from '../../../src/http-request/request-types' @@ -114,4 +116,61 @@ describe.sequential('http-request/response-reader — readIncomingResponse', () expect(response.text()).toBe('') expect(response.body.byteLength).toBe(0) }) + + test('decompresses a gzip Content-Encoding body', async () => { + const payload = JSON.stringify({ hello: 'gzip' }) + const compressed = await compressGzip(payload) + const msg = makeMsg({ + chunks: [compressed], + statusCode: 200, + headers: { 'content-encoding': 'gzip' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe(payload) + expect(response.json<{ hello: string }>().hello).toBe('gzip') + }) + + test('decompresses a br Content-Encoding body', async () => { + const payload = JSON.stringify({ hello: 'brotli' }) + const compressed = await compressBrotli(payload) + const msg = makeMsg({ + chunks: [compressed], + statusCode: 200, + headers: { 'content-encoding': 'br' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe(payload) + }) + + test('is case-insensitive on the encoding token', async () => { + const payload = 'plain text body' + const compressed = await compressGzip(payload) + const msg = makeMsg({ + chunks: [compressed], + statusCode: 200, + headers: { 'content-encoding': 'GZIP' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe(payload) + }) + + test('leaves the body untouched for identity / absent encoding', async () => { + const msg = makeMsg({ + chunks: [Buffer.from('raw')], + statusCode: 200, + headers: { 'content-encoding': 'identity' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe('raw') + }) + + test('does not attempt to decompress an empty body even when encoded', async () => { + const msg = makeMsg({ + chunks: [], + statusCode: 204, + headers: { 'content-encoding': 'gzip' }, + }) + const response = await readIncomingResponse(msg) + expect(response.body.byteLength).toBe(0) + }) }) From 9bdb3450ee6d89acfe8982de84966500a6e8847b Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 29 May 2026 01:32:47 -0400 Subject: [PATCH 2/5] fix(paths): walkUp emits 'D:/' not 'D:' at a Windows drive root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizePath('D:\\') collapses the trailing separator to 'D:' — correct for general paths ('D:' = current dir on D:), wrong for the filesystem root walkUp must yield. The final ancestor on Windows then differed from path.parse(dir).root, failing test/unit/paths/walk.test.mts on windows-latest. Add normalizeWalkDir: keeps the root slash on a bare drive letter, leaves every other path to normalizePath unchanged. --- src/paths/walk.ts | 22 +++++++++++++++++++++- test/unit/paths/walk.test.mts | 18 +++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/paths/walk.ts b/src/paths/walk.ts index 14fa07355..3aab6b34e 100644 --- a/src/paths/walk.ts +++ b/src/paths/walk.ts @@ -26,6 +26,26 @@ export interface WalkUpOptions { stopAt?: string | undefined } +// A bare Windows drive letter with no trailing slash. +const BARE_DRIVE_RE = /^[A-Za-z]:$/ + +/** + * Normalize a directory for `walkUp` output. Like `normalizePath`, but keeps + * the root slash on a bare Windows drive letter: `normalizePath('D:\\')` + * collapses the trailing separator to `'D:'` (correct for general paths, where + * `'D:'` means "current directory on D:"), yet the filesystem root `walkUp` + * must emit is `'D:/'` — matching `path.parse(dir).root`. Without the slash the + * final ancestor would differ from the actual root on Windows. + * + * @param dir - An absolute directory path. + * + * @returns The forward-slash-normalized path, with the drive root preserved. + */ +export function normalizeWalkDir(dir: string): string { + const normalized = normalizePath(dir) + return BARE_DRIVE_RE.test(normalized) ? `${normalized}/` : normalized +} + /** * Lazily yield `from` and each of its ancestor directories, up to and including * the filesystem root (or `stopAt`). Each yielded path is normalized to forward @@ -67,7 +87,7 @@ export function* walkUp( const stopDir = stopAt ? path.resolve(cwd, stopAt) : undefined let prev: string | undefined while (dir !== prev) { - yield normalizePath(dir) + yield normalizeWalkDir(dir) if (stopDir !== undefined && dir === stopDir) { return } diff --git a/test/unit/paths/walk.test.mts b/test/unit/paths/walk.test.mts index 457d994a2..b82b7d2dd 100644 --- a/test/unit/paths/walk.test.mts +++ b/test/unit/paths/walk.test.mts @@ -6,7 +6,23 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' -import { walkUp } from '../../../src/paths/walk' +import { normalizeWalkDir, walkUp } from '../../../src/paths/walk' + +describe('normalizeWalkDir', () => { + it('preserves the root slash on a bare Windows drive letter', () => { + expect(normalizeWalkDir('D:\\')).toBe('D:/') + expect(normalizeWalkDir('C:\\')).toBe('C:/') + }) + + it('normalizes backslashes and leaves non-root paths slash-free', () => { + expect(normalizeWalkDir('C:\\a\\b')).toBe('C:/a/b') + expect(normalizeWalkDir('/a/b/c')).toBe('/a/b/c') + }) + + it('leaves a posix root unchanged', () => { + expect(normalizeWalkDir('/')).toBe('/') + }) +}) // On Windows, `path.resolve('/a/b/c')` returns `D:\a\b\c` (current drive). // walkUp yields the normalized form `D:/a/b/c`. Strip the drive prefix on From 4da48069629bb5e139ab95a10ae13af57cd4ecf4 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 29 May 2026 01:35:17 -0400 Subject: [PATCH 3/5] fix(paths): normalizePath keeps the root slash on a drive root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the bug at its source rather than in walkUp: normalizePath('D:\\') collapsed the trailing separator to 'D:', but a drive ROOT's slash is significant — 'D:' alone means 'current directory on D:', a different location from the root 'D:/'. Now a bare-drive-letter result whose input had a separator right after the colon keeps its slash; drive-relative 'D:foo' is unaffected. Reverts the walkUp-side normalizeWalkDir workaround from the previous commit. Posix paths and every other shape are unchanged (565 paths+fs tests pass). --- src/paths/normalize.ts | 15 +++++++++++++++ src/paths/walk.ts | 22 +--------------------- test/unit/paths/normalize.test.mts | 12 ++++++++++++ test/unit/paths/walk.test.mts | 18 +----------------- 4 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/paths/normalize.ts b/src/paths/normalize.ts index 9231fb4f5..3269a4029 100644 --- a/src/paths/normalize.ts +++ b/src/paths/normalize.ts @@ -20,6 +20,9 @@ import { import { msysDriveRegExp, pathLikeToString, slashRegExp } from './_internal' +// A normalized path that is exactly a bare Windows drive letter (`C:`). +const DRIVE_LETTER_REGEXP = /^[A-Za-z]:$/ + // On Windows, convert MSYS drive notation to native: /c/path → C:/path export function msysDriveToNative(normalized: string): string { /* c8 ignore start - Windows-only branch. */ @@ -276,6 +279,18 @@ export function normalizePath(pathLike: string | Buffer | URL): string { if (collapsed.length === 0) { return prefix || '.' } + // A bare drive letter that came from a drive ROOT keeps its slash: `D:\` and + // `D:/` normalize to `D:/`, not `D:`. The trailing separator is significant + // on a drive root — `D:` alone means "current directory on D:", a different + // location. Detected by a separator immediately after the colon in the + // original input (index 2), so drive-relative `D:foo` is unaffected. + if ( + DRIVE_LETTER_REGEXP.test(collapsed) && + (StringPrototypeCharCodeAt(filepath, 2) === 47 /*'/'*/ || + StringPrototypeCharCodeAt(filepath, 2) === 92) /*'\\'*/ + ) { + return msysDriveToNative(`${prefix}${collapsed}/`) + } return msysDriveToNative(prefix + collapsed) } diff --git a/src/paths/walk.ts b/src/paths/walk.ts index 3aab6b34e..14fa07355 100644 --- a/src/paths/walk.ts +++ b/src/paths/walk.ts @@ -26,26 +26,6 @@ export interface WalkUpOptions { stopAt?: string | undefined } -// A bare Windows drive letter with no trailing slash. -const BARE_DRIVE_RE = /^[A-Za-z]:$/ - -/** - * Normalize a directory for `walkUp` output. Like `normalizePath`, but keeps - * the root slash on a bare Windows drive letter: `normalizePath('D:\\')` - * collapses the trailing separator to `'D:'` (correct for general paths, where - * `'D:'` means "current directory on D:"), yet the filesystem root `walkUp` - * must emit is `'D:/'` — matching `path.parse(dir).root`. Without the slash the - * final ancestor would differ from the actual root on Windows. - * - * @param dir - An absolute directory path. - * - * @returns The forward-slash-normalized path, with the drive root preserved. - */ -export function normalizeWalkDir(dir: string): string { - const normalized = normalizePath(dir) - return BARE_DRIVE_RE.test(normalized) ? `${normalized}/` : normalized -} - /** * Lazily yield `from` and each of its ancestor directories, up to and including * the filesystem root (or `stopAt`). Each yielded path is normalized to forward @@ -87,7 +67,7 @@ export function* walkUp( const stopDir = stopAt ? path.resolve(cwd, stopAt) : undefined let prev: string | undefined while (dir !== prev) { - yield normalizeWalkDir(dir) + yield normalizePath(dir) if (stopDir !== undefined && dir === stopDir) { return } diff --git a/test/unit/paths/normalize.test.mts b/test/unit/paths/normalize.test.mts index 902535b1f..1f5041ea5 100644 --- a/test/unit/paths/normalize.test.mts +++ b/test/unit/paths/normalize.test.mts @@ -50,6 +50,18 @@ describe('paths/normalize', () => { expect(normalizePath('D:\\projects\\app')).toBe('D:/projects/app') }) + it('should keep the root slash on a bare drive root', () => { + // A drive ROOT keeps its trailing slash — `D:` alone means "current + // directory on D:", a different location from the root `D:/`. + expect(normalizePath('D:\\')).toBe('D:/') + expect(normalizePath('C:/')).toBe('C:/') + }) + + it('should not add a slash to a drive-relative path', () => { + // `D:foo` (no separator after the colon) is drive-relative, not a root. + expect(normalizePath('D:foo')).toBe('D:foo') + }) + it('should normalize mixed slashes', () => { expect(normalizePath('C:\\Users/user\\file.txt')).toBe( 'C:/Users/user/file.txt', diff --git a/test/unit/paths/walk.test.mts b/test/unit/paths/walk.test.mts index b82b7d2dd..457d994a2 100644 --- a/test/unit/paths/walk.test.mts +++ b/test/unit/paths/walk.test.mts @@ -6,23 +6,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' -import { normalizeWalkDir, walkUp } from '../../../src/paths/walk' - -describe('normalizeWalkDir', () => { - it('preserves the root slash on a bare Windows drive letter', () => { - expect(normalizeWalkDir('D:\\')).toBe('D:/') - expect(normalizeWalkDir('C:\\')).toBe('C:/') - }) - - it('normalizes backslashes and leaves non-root paths slash-free', () => { - expect(normalizeWalkDir('C:\\a\\b')).toBe('C:/a/b') - expect(normalizeWalkDir('/a/b/c')).toBe('/a/b/c') - }) - - it('leaves a posix root unchanged', () => { - expect(normalizeWalkDir('/')).toBe('/') - }) -}) +import { walkUp } from '../../../src/paths/walk' // On Windows, `path.resolve('/a/b/c')` returns `D:\a\b\c` (current drive). // walkUp yields the normalized form `D:/a/b/c`. Strip the drive prefix on From 4ca17565541f9d09c37efada9e150981f3ac1a67 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 29 May 2026 01:47:04 -0400 Subject: [PATCH 4/5] test(paths): wrap the stopAt-equals-start walkUp case in withDrive walkUp('/a', { stopAt: '/a' }) yields the resolved path, which on Windows is 'D:/a' not '/a'. The assertion hardcoded the posix form, so it failed on windows-latest once the drive-root normalizePath fix let the run get past the earlier root-emit assertion. Wrap in the file's withDrive() helper like the sibling cases. --- test/unit/paths/walk.test.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/paths/walk.test.mts b/test/unit/paths/walk.test.mts index 457d994a2..effc5c843 100644 --- a/test/unit/paths/walk.test.mts +++ b/test/unit/paths/walk.test.mts @@ -54,7 +54,7 @@ describe('walkUp', () => { }) it('a start AT the stopAt yields just that one dir', () => { - expect([...walkUp('/a', { stopAt: '/a' })]).toStrictEqual(['/a']) + expect([...walkUp('/a', { stopAt: '/a' })]).toStrictEqual([withDrive('/a')]) }) it('is lazy — can break early without computing the whole chain', () => { From 465f88d9b202a8b115a5ea0dda539d9eae1ddfec Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 29 May 2026 02:11:03 -0400 Subject: [PATCH 5/5] test(external-tools): bump bazel + sbt resolve timeouts to 30s for Windows resolveBazel()/resolveSbt() do real PATH/binary resolution; Windows CI agents can take >10s on a cold cache, timing out vitest's 10s default (bazel flaked on PR #196's windows-latest run). Apply the established { timeout: 30_000 } per-test bump already used by jre/resolve + which. Variant fix across both untimed sibling resolvers. --- .../external-tools/bazel/resolve.test.mts | 23 +++++++++++++------ test/unit/external-tools/sbt/resolve.test.mts | 23 +++++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/test/unit/external-tools/bazel/resolve.test.mts b/test/unit/external-tools/bazel/resolve.test.mts index 5a2c5655b..6e9b05fd3 100644 --- a/test/unit/external-tools/bazel/resolve.test.mts +++ b/test/unit/external-tools/bazel/resolve.test.mts @@ -10,6 +10,11 @@ import { resolveBazel, } from '../../../../src/external-tools/bazel/resolve' +// resolveBazel() does real PATH/binary resolution; Windows CI agents can take +// >10s to return on a cold cache, blowing vitest's 10s default. Bump the +// per-test timeout, matching the jre/which win32-timeout fixes. +const RESOLVE_TIMEOUT = { timeout: 30_000 } + describe('external-tools/bazel/resolve', () => { beforeEach(() => { resetBazelResolution() @@ -22,14 +27,18 @@ describe('external-tools/bazel/resolve', () => { expect(resolveBazel()).toBe(resolveBazel()) }) - it('returns either a resolved shape or undefined on stock Node', async () => { - const result = await resolveBazel() - if (result !== undefined) { - expect(['vfs', 'path']).toContain(result.source) - } - }) + it( + 'returns either a resolved shape or undefined on stock Node', + RESOLVE_TIMEOUT, + async () => { + const result = await resolveBazel() + if (result !== undefined) { + expect(['vfs', 'path']).toContain(result.source) + } + }, + ) - it('resetBazelResolution clears the memo slot', async () => { + it('resetBazelResolution clears the memo slot', RESOLVE_TIMEOUT, async () => { const first = await resolveBazel() resetBazelResolution() const second = await resolveBazel() diff --git a/test/unit/external-tools/sbt/resolve.test.mts b/test/unit/external-tools/sbt/resolve.test.mts index 15f669f9d..d1d6b7dfa 100644 --- a/test/unit/external-tools/sbt/resolve.test.mts +++ b/test/unit/external-tools/sbt/resolve.test.mts @@ -9,6 +9,11 @@ import { resolveSbt, } from '../../../../src/external-tools/sbt/resolve' +// resolveSbt() does real PATH/binary resolution; Windows CI agents can take +// >10s to return on a cold cache, blowing vitest's 10s default. Bump the +// per-test timeout, matching the jre/which win32-timeout fixes. +const RESOLVE_TIMEOUT = { timeout: 30_000 } + describe('external-tools/sbt/resolve', () => { beforeEach(() => { resetSbtResolution() @@ -21,14 +26,18 @@ describe('external-tools/sbt/resolve', () => { expect(resolveSbt()).toBe(resolveSbt()) }) - it('returns either a resolved shape or undefined on stock Node', async () => { - const result = await resolveSbt() - if (result !== undefined) { - expect(['vfs', 'path']).toContain(result.source) - } - }) + it( + 'returns either a resolved shape or undefined on stock Node', + RESOLVE_TIMEOUT, + async () => { + const result = await resolveSbt() + if (result !== undefined) { + expect(['vfs', 'path']).toContain(result.source) + } + }, + ) - it('resetSbtResolution clears the memo slot', async () => { + it('resetSbtResolution clears the memo slot', RESOLVE_TIMEOUT, async () => { const first = await resolveSbt() resetSbtResolution() const second = await resolveSbt()