From 19b3ebce3e9d342579b0f345234c4dbe7583c786 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:10:09 +0200 Subject: [PATCH 1/5] fix(platform): reject path traversal and harden fs cache and retry boundaries --- .../adapters/fs/cache/size-estimator.test.ts | 26 ++++++ .../adapters/fs/cache/size-estimator.ts | 12 ++- .../adapters/fs/github/cache-scope.test.ts | 32 +++++++ .../adapters/fs/github/cache-scope.ts | 16 ++++ .../fs/github/directory-operations.test.ts | 29 ++++++ .../fs/github/directory-operations.ts | 6 +- .../adapters/fs/github/path-utils.test.ts | 45 +++++++++- src/platform/adapters/fs/github/path-utils.ts | 34 ++++++- .../fs/github/read-operations.test.ts | 27 ++++++ .../adapters/fs/github/read-operations.ts | 13 ++- .../adapters/fs/github/stat-operations.ts | 11 ++- .../fs/veryfront/adapter-helpers.test.ts | 33 ++++++- .../adapters/fs/veryfront/adapter-helpers.ts | 16 ++-- .../fs/veryfront/path-normalizer.test.ts | 53 ++++++++++- .../adapters/fs/veryfront/path-normalizer.ts | 59 ++++++++++-- .../adapters/fs/veryfront/retry.test.ts | 61 +++++++++++++ src/platform/adapters/fs/veryfront/retry.ts | 90 ++++++++++++++++--- src/platform/adapters/fs/veryfront/types.ts | 4 +- 18 files changed, 529 insertions(+), 38 deletions(-) create mode 100644 src/platform/adapters/fs/github/cache-scope.test.ts create mode 100644 src/platform/adapters/fs/github/cache-scope.ts diff --git a/src/platform/adapters/fs/cache/size-estimator.test.ts b/src/platform/adapters/fs/cache/size-estimator.test.ts index 078b93aff2..ff9ccf67fb 100644 --- a/src/platform/adapters/fs/cache/size-estimator.test.ts +++ b/src/platform/adapters/fs/cache/size-estimator.test.ts @@ -53,6 +53,32 @@ describe("estimateSize", () => { it("should handle nested objects", () => { assertJsonSize({ a: { b: { c: 1 } } }); }); + + it("marks cyclic values as uncacheable without throwing", () => { + const value: { self?: unknown } = {}; + value.self = value; + assertEquals(estimateSize(value), Number.MAX_SAFE_INTEGER); + }); + + it("marks BigInt-containing values as uncacheable without throwing", () => { + assertEquals(estimateSize({ value: 1n }), Number.MAX_SAFE_INTEGER); + }); + + it("contains failures from custom serialization hooks", () => { + const value = { + toJSON(): never { + throw new Error("serialization failed"); + }, + }; + assertEquals(estimateSize(value), Number.MAX_SAFE_INTEGER); + }); + + it("rejects objects whose serialization omits the value", () => { + assertEquals( + estimateSize({ toJSON: () => undefined }), + Number.MAX_SAFE_INTEGER, + ); + }); }); describe("primitives", () => { diff --git a/src/platform/adapters/fs/cache/size-estimator.ts b/src/platform/adapters/fs/cache/size-estimator.ts index 77e9661da4..96808c83d7 100644 --- a/src/platform/adapters/fs/cache/size-estimator.ts +++ b/src/platform/adapters/fs/cache/size-estimator.ts @@ -2,6 +2,16 @@ export function estimateSize(value: unknown): number { if (value instanceof Uint8Array) return value.length; if (typeof value === "string") return value.length * 2; if (value == null) return 8; - if (typeof value === "object") return JSON.stringify(value).length * 2; + if (typeof value === "object") { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? Number.MAX_SAFE_INTEGER : serialized.length * 2; + } catch { + // The cache cannot safely persist cyclic values, BigInts, or objects + // whose serialization hooks fail. Treat them as too large so callers + // reject admission without weakening memory limits. + return Number.MAX_SAFE_INTEGER; + } + } return 8; } diff --git a/src/platform/adapters/fs/github/cache-scope.test.ts b/src/platform/adapters/fs/github/cache-scope.test.ts new file mode 100644 index 0000000000..60e9fb1da1 --- /dev/null +++ b/src/platform/adapters/fs/github/cache-scope.test.ts @@ -0,0 +1,32 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { buildGitHubCacheRef } from "./cache-scope.ts"; + +describe("GitHub cache scope", () => { + it("isolates repositories that use the same ref", () => { + const first = buildGitHubCacheRef({ + owner: "owner-a", + repo: "site", + ref: "main", + }); + const second = buildGitHubCacheRef({ + owner: "owner-b", + repo: "site", + ref: "main", + }); + + assertEquals(first === second, false); + }); + + it("encodes delimiter characters in repository identity and refs", () => { + assertEquals( + buildGitHubCacheRef({ + owner: "owner:name", + repo: "site/name", + ref: "feature/cache:key", + }), + "owner%3Aname:site%2Fname:feature%2Fcache%3Akey", + ); + }); +}); diff --git a/src/platform/adapters/fs/github/cache-scope.ts b/src/platform/adapters/fs/github/cache-scope.ts new file mode 100644 index 0000000000..e9eab16694 --- /dev/null +++ b/src/platform/adapters/fs/github/cache-scope.ts @@ -0,0 +1,16 @@ +import type { ResolvedGitHubConfig } from "./types.ts"; + +/** + * Build the repository-scoped value passed to the shared GitHub cache-key + * builders. File caches can use a process-wide distributed backend, so a ref + * alone is not sufficient to isolate repositories with matching paths. + */ +export function buildGitHubCacheRef( + config: Pick, +): string { + return [ + encodeURIComponent(config.owner), + encodeURIComponent(config.repo), + encodeURIComponent(config.ref), + ].join(":"); +} diff --git a/src/platform/adapters/fs/github/directory-operations.test.ts b/src/platform/adapters/fs/github/directory-operations.test.ts index 1841289dda..c7c0975e9c 100644 --- a/src/platform/adapters/fs/github/directory-operations.test.ts +++ b/src/platform/adapters/fs/github/directory-operations.test.ts @@ -60,5 +60,34 @@ describe("GitHubDirectoryOperations", () => { it("should return empty array for non-existent directory", () => { assertEquals(createOps().readdir("/non-existent"), []); }); + + it("isolates directory cache entries by repository", () => { + const cache = new FileCache(); + const first = new GitHubDirectoryOperations( + mockConfig, + cache, + { + isDirectory: () => true, + getFilesInDirectory: () => [ + { path: "src/first.ts", sha: "first", size: 1, type: "blob" }, + ], + getSubdirectories: () => [], + } as any, + ); + const second = new GitHubDirectoryOperations( + { ...mockConfig, repo: "other-repo" }, + cache, + { + isDirectory: () => true, + getFilesInDirectory: () => [ + { path: "src/second.ts", sha: "second", size: 1, type: "blob" }, + ], + getSubdirectories: () => [], + } as any, + ); + + assertEquals(first.readdir("src")[0]?.name, "first.ts"); + assertEquals(second.readdir("src")[0]?.name, "second.ts"); + }); }); }); diff --git a/src/platform/adapters/fs/github/directory-operations.ts b/src/platform/adapters/fs/github/directory-operations.ts index 3e9d1baf4d..1467911128 100644 --- a/src/platform/adapters/fs/github/directory-operations.ts +++ b/src/platform/adapters/fs/github/directory-operations.ts @@ -4,6 +4,7 @@ import type { FileCache } from "../cache/file-cache.ts"; import type { GitHubStatOperations } from "./stat-operations.ts"; import type { DirectoryEntry, ResolvedGitHubConfig } from "./types.ts"; import { normalizeGitHubPath } from "./path-utils.ts"; +import { buildGitHubCacheRef } from "./cache-scope.ts"; const LOG_PREFIX = "[GitHubDirectoryOperations]"; @@ -17,7 +18,10 @@ export class GitHubDirectoryOperations { readdir(path: string): DirectoryEntry[] { const normalizedPath = normalizeGitHubPath(path, this.projectDir); - const cacheKey = buildGitHubDirCacheKey(this.config.ref, normalizedPath); + const cacheKey = buildGitHubDirCacheKey( + buildGitHubCacheRef(this.config), + normalizedPath, + ); const cached = this.cache.get(cacheKey); if (cached) return cached; diff --git a/src/platform/adapters/fs/github/path-utils.test.ts b/src/platform/adapters/fs/github/path-utils.test.ts index de6263d247..f69a511130 100644 --- a/src/platform/adapters/fs/github/path-utils.test.ts +++ b/src/platform/adapters/fs/github/path-utils.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { normalizeGitHubPath } from "./path-utils.ts"; @@ -28,5 +28,48 @@ describe("platform/adapters/fs/github/path-utils", () => { it("should default projectDir to empty string", () => { assertEquals(normalizeGitHubPath("/src/file.ts"), "src/file.ts"); }); + + it("only strips projectDir at a complete path-segment boundary", () => { + assertEquals( + normalizeGitHubPath("/application/file.ts", "/app"), + "application/file.ts", + ); + assertEquals( + normalizeGitHubPath("/app/file.ts", "/app/"), + "file.ts", + ); + }); + + it('normalizes "." segments away instead of rejecting them', () => { + assertEquals(normalizeGitHubPath("src/./file.ts"), "src/file.ts"); + assertEquals(normalizeGitHubPath("./src/file.ts"), "src/file.ts"); + // A projectDir of "." conventionally means the repository root. + assertEquals(normalizeGitHubPath("/src/file.ts", "."), "src/file.ts"); + }); + + it("rejects traversal segments instead of aliasing another file", () => { + for ( + const path of [ + "../secret.ts", + "src/../secret.ts", + "/project/../../secret.ts", + "../../../../user/repos", + ] + ) { + assertThrows( + () => normalizeGitHubPath(path, "/project"), + TypeError, + "traversal", + ); + } + }); + + it("rejects traversal segments in projectDir", () => { + assertThrows( + () => normalizeGitHubPath("src/file.ts", "/project/../other"), + TypeError, + "traversal", + ); + }); }); }); diff --git a/src/platform/adapters/fs/github/path-utils.ts b/src/platform/adapters/fs/github/path-utils.ts index 504631becd..175b136207 100644 --- a/src/platform/adapters/fs/github/path-utils.ts +++ b/src/platform/adapters/fs/github/path-utils.ts @@ -1,9 +1,35 @@ export function normalizeGitHubPath(path: string, projectDir: string = ""): string { - let normalized = path; + const normalizedPath = normalizePathSegments(path, "path"); + const normalizedProjectDir = normalizePathSegments(projectDir, "projectDir"); - if (projectDir && normalized.startsWith(projectDir)) { - normalized = normalized.slice(projectDir.length); + if ( + normalizedProjectDir && + (normalizedPath === normalizedProjectDir || + normalizedPath.startsWith(`${normalizedProjectDir}/`)) + ) { + return normalizedPath.slice(normalizedProjectDir.length).replace(/^\/+/, ""); } - return normalized.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/+/g, "/"); + return normalizedPath; +} + +function normalizePathSegments(value: string, label: string): string { + if (typeof value !== "string") { + throw new TypeError(`GitHub ${label} must be a string`); + } + + const collapsed = value.replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/"); + const segments: string[] = []; + for (const segment of collapsed.split("/")) { + // "." segments are legitimate no-ops (a projectDir of "." conventionally + // means the repository root); normalize them away instead of rejecting. + if (segment === ".") continue; + // ".." would escape the repository scope once the path is embedded in a + // GitHub API URL (WHATWG URL resolution collapses dot segments): reject. + if (segment === "..") { + throw new TypeError(`GitHub ${label} must not contain ".." traversal segments`); + } + segments.push(segment); + } + return segments.join("/"); } diff --git a/src/platform/adapters/fs/github/read-operations.test.ts b/src/platform/adapters/fs/github/read-operations.test.ts index 8f7f41c57d..21c38e9b08 100644 --- a/src/platform/adapters/fs/github/read-operations.test.ts +++ b/src/platform/adapters/fs/github/read-operations.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { GitHubReadOperations } from "./read-operations.ts"; +import { FileCache } from "../cache/file-cache.ts"; describe("GitHubReadOperations", () => { it("should export GitHubReadOperations class", () => { @@ -233,4 +234,30 @@ describe("GitHubReadOperations", () => { assertEquals(new TextDecoder().decode(result), "plain text"); }); }); + + describe("cache scoping", () => { + it("isolates content cache entries by repository", async () => { + const cache = new FileCache(); + // "Zmlyc3Q=" is base64 for "first"; "c2Vjb25k" is base64 for "second". + const first = new GitHubReadOperations( + mockConfig, + createMockClient({ + getContents: () => Promise.resolve({ type: "file", content: "Zmlyc3Q=" }), + }) as any, + cache as any, + createMockStatOps() as any, + ); + const second = new GitHubReadOperations( + { ...mockConfig, repo: "other-repo" }, + createMockClient({ + getContents: () => Promise.resolve({ type: "file", content: "c2Vjb25k" }), + }) as any, + cache as any, + createMockStatOps() as any, + ); + + assertEquals(await first.readTextFile("src/data.txt"), "first"); + assertEquals(await second.readTextFile("src/data.txt"), "second"); + }); + }); }); diff --git a/src/platform/adapters/fs/github/read-operations.ts b/src/platform/adapters/fs/github/read-operations.ts index 342a7a7028..28c40c544d 100644 --- a/src/platform/adapters/fs/github/read-operations.ts +++ b/src/platform/adapters/fs/github/read-operations.ts @@ -6,6 +6,7 @@ import type { GitHubApiClient } from "./github-api-client.ts"; import type { GitHubStatOperations } from "./stat-operations.ts"; import type { GitHubContentItem, ResolvedGitHubConfig } from "./types.ts"; import { normalizeGitHubPath } from "./path-utils.ts"; +import { buildGitHubCacheRef } from "./cache-scope.ts"; import { requireBoundedFileReadLimit } from "../../bounded-file-read.ts"; import { copyFixedUint8ArrayWithinLimit } from "../../bounded-text-reader.ts"; @@ -37,7 +38,10 @@ export class GitHubReadOperations { async readTextFile(path: string): Promise { const normalizedPath = normalizeGitHubPath(path, this.projectDir); - const cacheKey = buildGitHubContentCacheKey(this.config.ref, normalizedPath); + const cacheKey = buildGitHubContentCacheKey( + buildGitHubCacheRef(this.config), + normalizedPath, + ); const cached = this.cache.get(cacheKey); if (cached !== undefined) return cached; @@ -54,7 +58,10 @@ export class GitHubReadOperations { async readFile(path: string): Promise { const normalizedPath = normalizeGitHubPath(path, this.projectDir); - const cacheKey = buildGitHubBytesCacheKey(this.config.ref, normalizedPath); + const cacheKey = buildGitHubBytesCacheKey( + buildGitHubCacheRef(this.config), + normalizedPath, + ); const cached = this.cache.get(cacheKey); if (cached !== undefined) return cached; @@ -90,7 +97,7 @@ export class GitHubReadOperations { } const cacheKey = `${ - buildGitHubBytesCacheKey(this.config.ref, normalizedPath) + buildGitHubBytesCacheKey(buildGitHubCacheRef(this.config), normalizedPath) }:exact:${fileEntry.sha}`; const cached = this.cache.get(cacheKey); if (cached !== undefined) { diff --git a/src/platform/adapters/fs/github/stat-operations.ts b/src/platform/adapters/fs/github/stat-operations.ts index 288ebcd7a8..7f18adb978 100644 --- a/src/platform/adapters/fs/github/stat-operations.ts +++ b/src/platform/adapters/fs/github/stat-operations.ts @@ -10,6 +10,7 @@ import type { FileCache } from "../cache/file-cache.ts"; import type { GitHubApiClient } from "./github-api-client.ts"; import type { FileIndexEntry, FileInfo, GitHubTreeEntry, ResolvedGitHubConfig } from "./types.ts"; import { normalizeGitHubPath } from "./path-utils.ts"; +import { buildGitHubCacheRef } from "./cache-scope.ts"; const LOG_PREFIX = "[GitHubStatOperations]"; const RESOLVE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mdx", ".md"]; @@ -128,7 +129,10 @@ export class GitHubStatOperations { indexSize: this.fileIndex.size, }); - const cacheKey = buildGitHubStatCacheKey(this.config.ref, normalizedPath); + const cacheKey = buildGitHubStatCacheKey( + buildGitHubCacheRef(this.config), + normalizedPath, + ); const cached = this.cache.get(cacheKey); if (cached) return cached; @@ -182,7 +186,10 @@ export class GitHubStatOperations { await this.ensureIndex(); const normalizedPath = normalizeGitHubPath(basePath, this.projectDir); - const cacheKey = buildGitHubResolveCacheKey(this.config.ref, normalizedPath); + const cacheKey = buildGitHubResolveCacheKey( + buildGitHubCacheRef(this.config), + normalizedPath, + ); const cached = this.cache.get(cacheKey); if (cached !== undefined) return cached; diff --git a/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts b/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts index cf1bbd7739..361f27be32 100644 --- a/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts +++ b/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts @@ -1,6 +1,8 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { MAX_VERYFRONT_FILESYSTEM_RETRIES } from "#veryfront/utils/config-resource-limits.ts"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import { buildFileCacheOptions, buildRetryConfig, @@ -30,6 +32,35 @@ describe("veryfront adapter helpers", () => { }); }); + it("rejects retry counts that exceed the filesystem request budget", () => { + assertThrows( + () => + buildRetryConfig({ + maxRetries: MAX_VERYFRONT_FILESYSTEM_RETRIES + 1, + }), + RangeError, + "maxRetries", + ); + }); + + it("rejects invalid retry delays at direct adapter construction", () => { + for ( + const retry of [ + { initialDelay: -1 }, + { initialDelay: 0.5 }, + { maxDelay: MAX_TIMER_DELAY_MS + 1 }, + { initialDelay: 2, maxDelay: 1 }, + ] + ) { + assertThrows(() => buildRetryConfig(retry), RangeError); + } + assertEquals(buildRetryConfig({ initialDelay: 0, maxDelay: 0 }), { + maxRetries: DEFAULT_MAX_RETRIES, + initialDelay: 0, + maxDelay: 0, + }); + }); + it("builds file cache options with defaults", () => { assertEquals(buildFileCacheOptions(undefined), { enabled: true, diff --git a/src/platform/adapters/fs/veryfront/adapter-helpers.ts b/src/platform/adapters/fs/veryfront/adapter-helpers.ts index cce9deacda..a6feb177a8 100644 --- a/src/platform/adapters/fs/veryfront/adapter-helpers.ts +++ b/src/platform/adapters/fs/veryfront/adapter-helpers.ts @@ -1,6 +1,7 @@ import type { VeryfrontAPIConfig } from "../../veryfront-api-client/types.ts"; import type { FileCacheOptions } from "../cache/types.ts"; import type { ContentSource, FSAdapterConfig } from "./types.ts"; +import { normalizeFilesystemRetryConfig } from "#veryfront/utils/config-resource-limits.ts"; export const DEFAULT_MAX_RETRIES = 3; export const DEFAULT_INITIAL_RETRY_DELAY_MS = 1_000; @@ -16,12 +17,15 @@ type CacheOverrides = VeryfrontConfigOverrides["cache"]; export function buildRetryConfig( retry?: RetryOverrides, ): NonNullable { - return { - maxRetries: DEFAULT_MAX_RETRIES, - initialDelay: DEFAULT_INITIAL_RETRY_DELAY_MS, - maxDelay: DEFAULT_MAX_RETRY_DELAY_MS, - ...retry, - }; + return normalizeFilesystemRetryConfig( + retry, + { + maxRetries: DEFAULT_MAX_RETRIES, + initialDelay: DEFAULT_INITIAL_RETRY_DELAY_MS, + maxDelay: DEFAULT_MAX_RETRY_DELAY_MS, + }, + "retries-after-initial", + ); } export function buildFileCacheOptions( diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts index 0bb7809705..528415731f 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertExists, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { PathNormalizer } from "./path-normalizer.ts"; @@ -85,5 +85,56 @@ describe("PathNormalizer", () => { const normalizer = new PathNormalizer("/project"); assertEquals(normalizer.normalize("/project//src///page.tsx"), "src/page.tsx"); }); + + it("should only strip projectDir at a path-segment boundary", () => { + const normalizer = new PathNormalizer("/project/root"); + assertEquals( + normalizer.normalize("/project/root-other/src/page.tsx"), + "project/root-other/src/page.tsx", + ); + }); + + it("should reject traversal segments", () => { + const normalizer = new PathNormalizer("/project"); + assertThrows( + () => normalizer.normalize("/project/src/../secrets.ts"), + TypeError, + 'must not contain ".." segments', + ); + assertThrows( + () => normalizer.normalize("../../../../user/repos"), + TypeError, + 'must not contain ".." segments', + ); + }); + + it("should normalize current-directory segments away", () => { + const normalizer = new PathNormalizer("/project"); + assertEquals(normalizer.normalize("src/./page.tsx"), "src/page.tsx"); + assertEquals(normalizer.normalize("./src/page.tsx"), "src/page.tsx"); + }); + + it("should reject backslashes and control characters", () => { + const normalizer = new PathNormalizer(); + assertThrows( + () => normalizer.normalize("src\\secrets.ts"), + TypeError, + "must use forward slashes", + ); + assertThrows( + () => normalizer.normalize("src/\u0000secrets.ts"), + TypeError, + "must not contain control characters", + ); + }); + + it("should reject unbounded paths", () => { + const normalizer = new PathNormalizer(); + assertThrows( + () => normalizer.normalize("a".repeat(4_097)), + TypeError, + "exceeds the 4096-character limit", + ); + }); }); }); diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.ts b/src/platform/adapters/fs/veryfront/path-normalizer.ts index 123b518493..e6c6fb555a 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.ts @@ -1,26 +1,58 @@ import { logger as baseLogger } from "#veryfront/utils"; const logger = baseLogger.component("path-normalizer"); +const MAX_PATH_CODE_UNITS = 4_096; + +function hasAsciiControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || codeUnit === 0x7f) return true; + } + return false; +} export class PathNormalizer { - constructor(private readonly projectDir?: string) {} + private readonly projectDirPrefix?: string; + + constructor(private readonly projectDir?: string) { + if (projectDir !== undefined) { + this.assertSafePath(projectDir, "project directory"); + this.projectDirPrefix = projectDir === "/" ? "/" : projectDir.replace(/\/+$/g, ""); + } + } getProjectDir(): string | undefined { return this.projectDir; } normalize(path: string): string { - const projectDir = this.projectDir; - const wasAbsoluteInProject = projectDir != null && path.startsWith(projectDir); + this.assertSafePath(path, "path"); + + const projectDir = this.projectDirPrefix; + const wasAbsoluteInProject = projectDir !== undefined && + (projectDir === "/" + ? path.startsWith("/") + : path === projectDir || path.startsWith(`${projectDir}/`)); let normalized = path; if (wasAbsoluteInProject) { - normalized = normalized.slice(projectDir.length); + normalized = projectDir === "/" ? normalized.slice(1) : normalized.slice(projectDir.length); } normalized = normalized.replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/"); + // "." segments are legitimate no-ops (projectDir "." conventionally means + // the project root elsewhere in this codebase); drop them instead of + // rejecting the path. ".." segments would alias a path outside the + // project scope, so those are rejected outright. + const segments = normalized.split("/").filter((segment) => segment !== "."); + const traversalSegment = segments.find((segment) => segment === ".."); + if (traversalSegment) { + throw new TypeError('Filesystem path must not contain ".." segments'); + } + normalized = segments.join("/"); + if (normalized.startsWith("@/")) { const original = normalized; normalized = normalized.slice(2); @@ -31,10 +63,27 @@ export class PathNormalizer { logger.debug("Converted absolute to relative path", { absolute: path, relative: normalized, - projectDir, + projectDir: this.projectDir, }); } return normalized; } + + private assertSafePath(path: string, label: string): void { + if (typeof path !== "string") { + throw new TypeError(`Filesystem ${label} must be a string`); + } + if (path.length > MAX_PATH_CODE_UNITS) { + throw new TypeError( + `Filesystem ${label} exceeds the ${MAX_PATH_CODE_UNITS}-character limit`, + ); + } + if (hasAsciiControlCharacter(path)) { + throw new TypeError(`Filesystem ${label} must not contain control characters`); + } + if (path.includes("\\")) { + throw new TypeError(`Filesystem ${label} must use forward slashes`); + } + } } diff --git a/src/platform/adapters/fs/veryfront/retry.test.ts b/src/platform/adapters/fs/veryfront/retry.test.ts index 0624158a6a..4aa99c2a04 100644 --- a/src/platform/adapters/fs/veryfront/retry.test.ts +++ b/src/platform/adapters/fs/veryfront/retry.test.ts @@ -156,5 +156,66 @@ describe("platform/adapters/fs/veryfront/retry", () => { assertEquals(result, "ok"); assertEquals(callCount, 2); }); + + it("does not retry invalid non-HTTP status values", async () => { + for (const status of [499.5, 600, Number.POSITIVE_INFINITY]) { + let callCount = 0; + await assertRejects(() => + withRetryOnTransient(() => { + callCount++; + const error = new Error("invalid status"); + (error as Error & { status: number }).status = status; + throw error; + }, "test") + ); + assertEquals(callCount, 1); + } + }); + + it("contains hostile throwable introspection hooks", async () => { + let callCount = 0; + const hostile = new Proxy({}, { + getOwnPropertyDescriptor(): never { + throw new Error("descriptor trap"); + }, + get(): never { + throw new Error("get trap"); + }, + }); + + let caught: unknown; + try { + await withRetryOnTransient(() => { + callCount++; + throw hostile; + }, "test"); + } catch (error) { + caught = error; + } + + assertEquals(callCount, 1); + assertEquals(caught === hostile, true); + }); + + it("uses own status data without invoking a hostile message accessor", async () => { + let callCount = 0; + const result = await withRetryOnTransient(() => { + callCount++; + if (callCount === 1) { + const error = new Error("hidden"); + Object.defineProperty(error, "message", { + get(): never { + throw new Error("message getter"); + }, + }); + (error as Error & { status: number }).status = 503; + throw error; + } + return Promise.resolve("ok"); + }, "test"); + + assertEquals(result, "ok"); + assertEquals(callCount, 2); + }); }); }); diff --git a/src/platform/adapters/fs/veryfront/retry.ts b/src/platform/adapters/fs/veryfront/retry.ts index e28bc7de3f..021472a443 100644 --- a/src/platform/adapters/fs/veryfront/retry.ts +++ b/src/platform/adapters/fs/veryfront/retry.ts @@ -9,34 +9,100 @@ import { logger as baseLogger } from "#veryfront/utils"; import { retryWithBackoff } from "#veryfront/errors/error-handlers.ts"; +import { + isNativeErrorWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; const logger = baseLogger.component("fs-retry"); /** Delay between retries in milliseconds */ const RETRY_DELAY_MS = 500; +function getOwnDataProperty( + value: unknown, + key: PropertyKey, +): unknown { + if ( + (typeof value !== "object" || value === null) && + typeof value !== "function" + ) { + return undefined; + } + + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +} + +function getSafeErrorMessage(error: unknown): string { + if (isNativeErrorWithoutHooks(error)) { + const message = getOwnDataProperty(error, "message"); + return typeof message === "string" ? message : ""; + } + + switch (typeof error) { + case "string": + return error; + case "number": + case "boolean": + case "bigint": + case "symbol": + case "undefined": + return String(error); + default: + return ""; + } +} + +function isNativeTypeError(error: unknown): boolean { + if (!isNativeErrorWithoutHooks(error)) return false; + + try { + let prototype = Object.getPrototypeOf(error); + while (prototype !== null) { + if (prototype === TypeError.prototype) return true; + if (isProxyWithoutHooks(prototype)) return false; + prototype = Object.getPrototypeOf(prototype); + } + } catch { + return false; + } + + return false; +} + /** Check if an error is likely transient (network issue or server error) */ function isTransientError(error: unknown): boolean { // Narrow TypeError to known fetch/network failure messages only. // Bare `error.message.includes("fetch")` is too broad and can match // non-network TypeErrors (e.g., type validation mentioning "fetch"). - if (error instanceof TypeError) { - const msg = error.message; + const message = getSafeErrorMessage(error); + if (isNativeTypeError(error)) { if ( - msg.includes("fetch failed") || // Deno runtime fetch failure - msg.includes("Failed to fetch") || // browser/undici fetch failure - msg.includes("error sending request") || - msg.includes("NetworkError when attempting to fetch") || - msg.includes("network error") // documented Fetch API network error string + message.includes("fetch failed") || // Deno runtime fetch failure + message.includes("Failed to fetch") || // browser/undici fetch failure + message.includes("error sending request") || + message.includes("NetworkError when attempting to fetch") || + message.includes("network error") // documented Fetch API network error string ) { return true; } } - const status = (error as { status?: number })?.status; - if (typeof status === "number" && status >= 500) return true; + const status = getOwnDataProperty(error, "status"); + if ( + typeof status === "number" && + Number.isInteger(status) && + status >= 500 && + status <= 599 + ) { + return true; + } - const message = error instanceof Error ? error.message : String(error); if ( message.includes("ECONNRESET") || message.includes("ECONNREFUSED") || @@ -44,7 +110,7 @@ function isTransientError(error: unknown): boolean { message.includes("ENETUNREACH") || message.includes("socket hang up") || message.includes("network error") // specific phrase; see note below - // Note: bare "network" intentionally omitted — too broad, matches unrelated + // Note: bare "network" intentionally omitted because it matches unrelated // validation errors that mention "network settings" etc. The two-word // "network error" phrase is specific enough to avoid those false positives. ) { @@ -67,7 +133,7 @@ export function withRetryOnTransient( computeDelay: () => RETRY_DELAY_MS, shouldRetry: (error) => isTransientError(error), onRetry: ({ error }) => { - logger.warn(`${context} — transient error, retrying once`, { + logger.warn(`${context}: transient error, retrying once`, { error: error.message, }); }, diff --git a/src/platform/adapters/fs/veryfront/types.ts b/src/platform/adapters/fs/veryfront/types.ts index d481001200..c00849a8f6 100644 --- a/src/platform/adapters/fs/veryfront/types.ts +++ b/src/platform/adapters/fs/veryfront/types.ts @@ -124,8 +124,10 @@ export interface FSAdapterConfig { ttl?: number; }; retry?: { + /** Retries after the initial request, from 0 through 9. */ maxRetries?: number; - retryDelay?: number; + initialDelay?: number; + maxDelay?: number; }; }; github?: GitHubConfig; From 69cfb7add68ac651367b9b6e6dc1e9d5eacb5990 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:10:28 +0200 Subject: [PATCH 2/5] chore: drop unused stringifyJsonValue imports to unblock pre-push lint --- extensions/ext-llm-anthropic/src/anthropic-request-builder.ts | 1 - .../ext-llm-openai/src/openai-responses-request-builder.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts b/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts index 4d9d75f6f4..6158b89e21 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts @@ -2,7 +2,6 @@ import { jsonValuesEqual, readProviderOptions, readRecord, - stringifyJsonValue, stringifyToolResultValue, unwrapToolInputSchema, } from "veryfront/provider/shared"; diff --git a/extensions/ext-llm-openai/src/openai-responses-request-builder.ts b/extensions/ext-llm-openai/src/openai-responses-request-builder.ts index e4d358c5bb..093fa001f2 100644 --- a/extensions/ext-llm-openai/src/openai-responses-request-builder.ts +++ b/extensions/ext-llm-openai/src/openai-responses-request-builder.ts @@ -1,7 +1,6 @@ import { jsonValuesEqual, readProviderOptions, - stringifyJsonValue, stringifyToolArguments, stringifyToolResultValue, unwrapToolInputSchema, From dde47facb11ac17b6f69532073fcac426644213f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:24:59 +0200 Subject: [PATCH 3/5] Close Veryfront FS introspection and path gaps --- .../adapters/fs/veryfront/path-normalizer.test.ts | 10 ++++++++++ src/platform/adapters/fs/veryfront/path-normalizer.ts | 11 +++++------ src/platform/adapters/fs/veryfront/retry.ts | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts index 528415731f..7ca8f6bd13 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts @@ -17,6 +17,16 @@ describe("PathNormalizer", () => { it("should be instantiable with projectDir", () => { assertExists(new PathNormalizer("/project")); }); + + it("should reject traversal segments in projectDir", () => { + for (const projectDir of ["/project/..", "../project", "/project//../root"]) { + assertThrows( + () => new PathNormalizer(projectDir), + TypeError, + 'project directory must not contain ".." segments', + ); + } + }); }); describe("normalize", () => { diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.ts b/src/platform/adapters/fs/veryfront/path-normalizer.ts index e6c6fb555a..4fef5aeb8b 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.ts @@ -44,13 +44,9 @@ export class PathNormalizer { // "." segments are legitimate no-ops (projectDir "." conventionally means // the project root elsewhere in this codebase); drop them instead of - // rejecting the path. ".." segments would alias a path outside the - // project scope, so those are rejected outright. + // rejecting the path. Parent-directory segments were rejected before any + // configured project prefix could be stripped. const segments = normalized.split("/").filter((segment) => segment !== "."); - const traversalSegment = segments.find((segment) => segment === ".."); - if (traversalSegment) { - throw new TypeError('Filesystem path must not contain ".." segments'); - } normalized = segments.join("/"); if (normalized.startsWith("@/")) { @@ -85,5 +81,8 @@ export class PathNormalizer { if (path.includes("\\")) { throw new TypeError(`Filesystem ${label} must use forward slashes`); } + if (path.split("/").some((segment) => segment === "..")) { + throw new TypeError(`Filesystem ${label} must not contain ".." segments`); + } } } diff --git a/src/platform/adapters/fs/veryfront/retry.ts b/src/platform/adapters/fs/veryfront/retry.ts index 021472a443..d691ffab0b 100644 --- a/src/platform/adapters/fs/veryfront/retry.ts +++ b/src/platform/adapters/fs/veryfront/retry.ts @@ -134,7 +134,7 @@ export function withRetryOnTransient( shouldRetry: (error) => isTransientError(error), onRetry: ({ error }) => { logger.warn(`${context}: transient error, retrying once`, { - error: error.message, + error: getSafeErrorMessage(error), }); }, }); From 2da077de76a69983ccd55af127fd6e7c065d0027 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:36:57 +0200 Subject: [PATCH 4/5] Ratchet platform adapter helper test typechecking --- scripts/lint/test-typecheck-baseline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 059eb234ff..1a5ea75c0a 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -35,7 +35,6 @@ "src/middleware/core/pipeline/composer.test.ts", "src/modules/import-map/preloader.test.ts", "src/modules/react-loader/ssr-module-loader.stress.test.ts", - "src/platform/adapters/fs/veryfront/adapter-helpers.test.ts", "src/platform/adapters/fs/veryfront/directory-operations.test.ts", "src/platform/adapters/redis/node.test.ts", "src/rendering/chunk-optimizer.test.ts", From ed1a04d1de9821d68f4c7d740a1bb3afb8bb686a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:56:32 +0200 Subject: [PATCH 5/5] fix(platform): preserve filesystem config failures --- src/platform/adapters/README.md | 3 +- src/platform/adapters/fs/integration.test.ts | 24 +++++++++++++- src/platform/adapters/fs/integration.ts | 4 +++ .../fs/veryfront/adapter-helpers.test.ts | 11 ++++--- .../adapters/fs/veryfront/adapter-helpers.ts | 32 +++++++++++++------ .../fs/veryfront/path-normalizer.test.ts | 6 ++++ .../adapters/fs/veryfront/path-normalizer.ts | 20 +++++++++--- src/platform/adapters/fs/veryfront/types.ts | 2 +- 8 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/platform/adapters/README.md b/src/platform/adapters/README.md index 053596edc4..caeacd9323 100644 --- a/src/platform/adapters/README.md +++ b/src/platform/adapters/README.md @@ -237,7 +237,8 @@ interface FSAdapterConfig { }; retry?: { maxRetries?: number; - retryDelay?: number; + initialDelay?: number; + maxDelay?: number; }; }; } diff --git a/src/platform/adapters/fs/integration.test.ts b/src/platform/adapters/fs/integration.test.ts index 7ed6f9469a..781dfdaa5a 100644 --- a/src/platform/adapters/fs/integration.test.ts +++ b/src/platform/adapters/fs/integration.test.ts @@ -1,5 +1,10 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertExists, + assertInstanceOf, + assertRejects, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createFSAdapterFromConfig, @@ -8,6 +13,7 @@ import { isFSAdapterConfigured, } from "./integration.ts"; import { denoAdapter } from "../deno.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; describe("integration.ts", () => { it("should export enhanceAdapterWithFS function", () => { @@ -94,6 +100,22 @@ describe("integration.ts", () => { }); describe("enhanceAdapterWithFS error fallback", () => { + it("should preserve invalid retry configuration instead of changing filesystems", async () => { + let rejection: unknown; + try { + await enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "veryfront-api", + veryfront: { retry: { maxRetries: Number.MAX_SAFE_INTEGER } }, + }, + }); + } catch (error) { + rejection = error; + } + assertInstanceOf(rejection, VeryfrontError); + assertEquals(rejection.slug, "config-validation-failed"); + }); + it("should fall back to original adapter for unsupported type", async () => { const adapter = await enhanceAdapterWithFS(denoAdapter, { fs: { type: "unsupported-type" as any }, diff --git a/src/platform/adapters/fs/integration.ts b/src/platform/adapters/fs/integration.ts index 3345d5abdb..f430241da4 100644 --- a/src/platform/adapters/fs/integration.ts +++ b/src/platform/adapters/fs/integration.ts @@ -4,6 +4,7 @@ import { createFSAdapter } from "./factory.ts"; import { wrapFSAdapter } from "./wrapper.ts"; import { logger as baseLogger } from "#veryfront/utils"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; const logger = baseLogger.component("fs-integration"); @@ -63,6 +64,9 @@ export function enhanceAdapterWithFS( return enhancedAdapter; } catch (error) { + if (error instanceof VeryfrontError && error.slug === "config-validation-failed") { + throw error; + } logger.error("Failed to initialize FSAdapter", { error: error instanceof Error ? error.message : String(error), type: fsType, diff --git a/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts b/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts index 361f27be32..eeb0e779ee 100644 --- a/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts +++ b/src/platform/adapters/fs/veryfront/adapter-helpers.test.ts @@ -1,8 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { MAX_VERYFRONT_FILESYSTEM_RETRIES } from "#veryfront/utils/config-resource-limits.ts"; import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; import { buildFileCacheOptions, buildRetryConfig, @@ -33,14 +34,16 @@ describe("veryfront adapter helpers", () => { }); it("rejects retry counts that exceed the filesystem request budget", () => { - assertThrows( + const error = assertThrows( () => buildRetryConfig({ maxRetries: MAX_VERYFRONT_FILESYSTEM_RETRIES + 1, }), - RangeError, + VeryfrontError, "maxRetries", ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-validation-failed"); }); it("rejects invalid retry delays at direct adapter construction", () => { @@ -52,7 +55,7 @@ describe("veryfront adapter helpers", () => { { initialDelay: 2, maxDelay: 1 }, ] ) { - assertThrows(() => buildRetryConfig(retry), RangeError); + assertThrows(() => buildRetryConfig(retry), VeryfrontError); } assertEquals(buildRetryConfig({ initialDelay: 0, maxDelay: 0 }), { maxRetries: DEFAULT_MAX_RETRIES, diff --git a/src/platform/adapters/fs/veryfront/adapter-helpers.ts b/src/platform/adapters/fs/veryfront/adapter-helpers.ts index a6feb177a8..d103458dbf 100644 --- a/src/platform/adapters/fs/veryfront/adapter-helpers.ts +++ b/src/platform/adapters/fs/veryfront/adapter-helpers.ts @@ -2,6 +2,8 @@ import type { VeryfrontAPIConfig } from "../../veryfront-api-client/types.ts"; import type { FileCacheOptions } from "../cache/types.ts"; import type { ContentSource, FSAdapterConfig } from "./types.ts"; import { normalizeFilesystemRetryConfig } from "#veryfront/utils/config-resource-limits.ts"; +import { CONFIG_VALIDATION_FAILED } from "#veryfront/errors/error-registry/config.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; export const DEFAULT_MAX_RETRIES = 3; export const DEFAULT_INITIAL_RETRY_DELAY_MS = 1_000; @@ -17,15 +19,27 @@ type CacheOverrides = VeryfrontConfigOverrides["cache"]; export function buildRetryConfig( retry?: RetryOverrides, ): NonNullable { - return normalizeFilesystemRetryConfig( - retry, - { - maxRetries: DEFAULT_MAX_RETRIES, - initialDelay: DEFAULT_INITIAL_RETRY_DELAY_MS, - maxDelay: DEFAULT_MAX_RETRY_DELAY_MS, - }, - "retries-after-initial", - ); + try { + return normalizeFilesystemRetryConfig( + retry, + { + maxRetries: DEFAULT_MAX_RETRIES, + initialDelay: DEFAULT_INITIAL_RETRY_DELAY_MS, + maxDelay: DEFAULT_MAX_RETRY_DELAY_MS, + }, + "retries-after-initial", + ); + } catch (error) { + if (error instanceof VeryfrontError && error.slug === "config-validation-failed") { + throw error; + } + throw CONFIG_VALIDATION_FAILED.create({ + detail: error instanceof Error + ? error.message + : "Invalid Veryfront filesystem retry configuration", + cause: error, + }); + } } export function buildFileCacheOptions( diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts index 7ca8f6bd13..5765f82d9c 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts @@ -50,6 +50,12 @@ describe("PathNormalizer", () => { assertEquals(normalizer.normalize("/project/src/file.ts"), "src/file.ts"); }); + it("should canonicalize current-directory segments before stripping projectDir", () => { + const normalizer = new PathNormalizer("/project/./root"); + assertEquals(normalizer.normalize("/project/root/src/file.ts"), "src/file.ts"); + assertEquals(normalizer.normalize("/project/./root/src/file.ts"), "src/file.ts"); + }); + it("should not modify path without projectDir prefix", () => { const normalizer = new PathNormalizer("/project"); assertEquals(normalizer.normalize("/other/src/file.ts"), "other/src/file.ts"); diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.ts b/src/platform/adapters/fs/veryfront/path-normalizer.ts index 4fef5aeb8b..ba334dac79 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.ts @@ -11,13 +11,24 @@ function hasAsciiControlCharacter(value: string): boolean { return false; } +function normalizeForComparison(value: string): string { + const isAbsolute = value.startsWith("/"); + const normalized = value + .replace(/^\/+|\/+$/g, "") + .replace(/\/+/g, "/") + .split("/") + .filter((segment) => segment !== ".") + .join("/"); + return isAbsolute ? `/${normalized}` : normalized; +} + export class PathNormalizer { private readonly projectDirPrefix?: string; constructor(private readonly projectDir?: string) { if (projectDir !== undefined) { this.assertSafePath(projectDir, "project directory"); - this.projectDirPrefix = projectDir === "/" ? "/" : projectDir.replace(/\/+$/g, ""); + this.projectDirPrefix = normalizeForComparison(projectDir); } } @@ -27,14 +38,15 @@ export class PathNormalizer { normalize(path: string): string { this.assertSafePath(path, "path"); + const normalizedPath = normalizeForComparison(path); const projectDir = this.projectDirPrefix; const wasAbsoluteInProject = projectDir !== undefined && (projectDir === "/" - ? path.startsWith("/") - : path === projectDir || path.startsWith(`${projectDir}/`)); + ? normalizedPath.startsWith("/") + : normalizedPath === projectDir || normalizedPath.startsWith(`${projectDir}/`)); - let normalized = path; + let normalized = normalizedPath; if (wasAbsoluteInProject) { normalized = projectDir === "/" ? normalized.slice(1) : normalized.slice(projectDir.length); diff --git a/src/platform/adapters/fs/veryfront/types.ts b/src/platform/adapters/fs/veryfront/types.ts index c00849a8f6..3f3db06fd6 100644 --- a/src/platform/adapters/fs/veryfront/types.ts +++ b/src/platform/adapters/fs/veryfront/types.ts @@ -124,7 +124,7 @@ export interface FSAdapterConfig { ttl?: number; }; retry?: { - /** Retries after the initial request, from 0 through 9. */ + /** Retries after the initial request, bounded by `MAX_VERYFRONT_FILESYSTEM_RETRIES`. */ maxRetries?: number; initialDelay?: number; maxDelay?: number;