Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
jsonValuesEqual,
readProviderOptions,
readRecord,
stringifyJsonValue,
stringifyToolResultValue,
unwrapToolInputSchema,
} from "veryfront/provider/shared";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
jsonValuesEqual,
readProviderOptions,
stringifyJsonValue,
stringifyToolArguments,
stringifyToolResultValue,
unwrapToolInputSchema,
Expand Down
1 change: 0 additions & 1 deletion scripts/lint/test-typecheck-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/platform/adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ interface FSAdapterConfig {
};
retry?: {
maxRetries?: number;
retryDelay?: number;
initialDelay?: number;
maxDelay?: number;
};
};
}
Expand Down
26 changes: 26 additions & 0 deletions src/platform/adapters/fs/cache/size-estimator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
12 changes: 11 additions & 1 deletion src/platform/adapters/fs/cache/size-estimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
32 changes: 32 additions & 0 deletions src/platform/adapters/fs/github/cache-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
16 changes: 16 additions & 0 deletions src/platform/adapters/fs/github/cache-scope.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedGitHubConfig, "owner" | "repo" | "ref">,
): string {
return [
encodeURIComponent(config.owner),
encodeURIComponent(config.repo),
encodeURIComponent(config.ref),
].join(":");
}
29 changes: 29 additions & 0 deletions src/platform/adapters/fs/github/directory-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
6 changes: 5 additions & 1 deletion src/platform/adapters/fs/github/directory-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

Expand All @@ -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<DirectoryEntry[]>(cacheKey);
if (cached) return cached;
Expand Down
45 changes: 44 additions & 1 deletion src/platform/adapters/fs/github/path-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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",
);
});
});
});
34 changes: 30 additions & 4 deletions src/platform/adapters/fs/github/path-utils.ts
Original file line number Diff line number Diff line change
@@ -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("/");
}
27 changes: 27 additions & 0 deletions src/platform/adapters/fs/github/read-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
});
13 changes: 10 additions & 3 deletions src/platform/adapters/fs/github/read-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -37,7 +38,10 @@ export class GitHubReadOperations {

async readTextFile(path: string): Promise<string> {
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<string>(cacheKey);
if (cached !== undefined) return cached;

Expand All @@ -54,7 +58,10 @@ export class GitHubReadOperations {

async readFile(path: string): Promise<Uint8Array> {
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<Uint8Array>(cacheKey);
if (cached !== undefined) return cached;

Expand Down Expand Up @@ -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<Uint8Array>(cacheKey);
if (cached !== undefined) {
Expand Down
11 changes: 9 additions & 2 deletions src/platform/adapters/fs/github/stat-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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<FileInfo>(cacheKey);
if (cached) return cached;

Expand Down Expand Up @@ -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<string | null>(cacheKey);
if (cached !== undefined) return cached;

Expand Down
Loading
Loading