Skip to content

Commit 6f15c9e

Browse files
khaliqgantclaude
andauthored
fix(core): reject writes with an oversized path segment (#353)
Local relayfile mounts materialize each path segment as a real filesystem entry. Most filesystems (ext4, APFS, ...) reject names over 255 bytes with ENAMETOOLONG -- worse once a mount's own atomic-write suffix (e.g. `.tmp-<random>`) is appended to the leaf segment. A provider adapter that builds a path segment from unbounded content (a long title, etc.) could produce a canonical path no mount could ever materialize, and since nothing revisits an already-written path, that failure recurred forever on every sync cycle. Root cause of the incident this fixes: relayfile-adapters#241 capped the reddit post title slug after a 275-byte segment made the `reddit-monitor` persona's sandbox mount fail continuously. That was a point fix in one provider adapter; this adds a shared guard at both write entry points (`writeFile` and `applyWebhookEnvelope`) so no provider can reintroduce the same class of bug. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 10f4472 commit 6f15c9e

4 files changed

Lines changed: 152 additions & 1 deletion

File tree

packages/core/src/files.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { hasOversizedPathSegment, MAX_PATH_SEGMENT_BYTES, writeFile } from "./files.js";
4+
import type { StorageAdapter } from "./storage.js";
5+
6+
describe("hasOversizedPathSegment", () => {
7+
it("accepts a path whose every segment is at the byte limit", () => {
8+
const segment = "a".repeat(MAX_PATH_SEGMENT_BYTES - ".json".length);
9+
expect(hasOversizedPathSegment(`/reddit/subreddits/localllama/posts/${segment}.json`)).toBe(false);
10+
});
11+
12+
it("rejects a path with one segment over the byte limit", () => {
13+
const segment = "a".repeat(MAX_PATH_SEGMENT_BYTES + 1);
14+
expect(hasOversizedPathSegment(`/reddit/subreddits/localllama/posts/${segment}.json`)).toBe(true);
15+
});
16+
17+
it("counts UTF-8 encoded bytes, not JS string length", () => {
18+
// Each "é" is 1 UTF-16 code unit but 2 UTF-8 bytes.
19+
const segment = "é".repeat(MAX_PATH_SEGMENT_BYTES); // 200 chars, 400 bytes
20+
expect(hasOversizedPathSegment(`/x/${segment}`)).toBe(true);
21+
});
22+
23+
it("ignores empty segments from a normalized path", () => {
24+
expect(hasOversizedPathSegment("/reddit/subreddits/localllama.json")).toBe(false);
25+
});
26+
27+
it("reproduces the real oversized r/LocalLLaMA post slug that broke the mount", () => {
28+
const badPath =
29+
"/reddit/subreddits/localllama/posts/i-just-don-t-get-it-these-big-tech-companies-can-illegally-" +
30+
"scrape-the-entire-internet-and-gatekeep-their-better-models-behind-higher-prices-so-it-s-natural-" +
31+
"that-people-look-for-affordable-options-and-there-will-be-providers-who-apparently-distill-models-" +
32+
"from-them__1uvwn9q.json";
33+
expect(hasOversizedPathSegment(badPath)).toBe(true);
34+
});
35+
});
36+
37+
describe("writeFile", () => {
38+
function storageStub(overrides: Partial<StorageAdapter> = {}): StorageAdapter {
39+
return {
40+
getFile: () => null,
41+
listFiles: () => [],
42+
putFile: () => {},
43+
deleteFile: () => {},
44+
appendEvent: () => {},
45+
listEvents: () => ({ items: [], nextCursor: null }),
46+
getRecentEvents: () => [],
47+
getOperation: () => null,
48+
putOperation: () => {},
49+
listOperations: () => ({ items: [], nextCursor: null }),
50+
nextRevision: () => "rev_1",
51+
nextOperationId: () => "op_1",
52+
nextEventId: () => "evt_1",
53+
enqueueWriteback: () => {},
54+
getPendingWritebacks: () => [],
55+
getWorkspaceId: () => "ws_test",
56+
...overrides,
57+
} as StorageAdapter;
58+
}
59+
60+
it("rejects a write whose path has an oversized segment before touching storage", () => {
61+
const segment = "a".repeat(MAX_PATH_SEGMENT_BYTES + 1);
62+
let storageTouched = false;
63+
const storage = storageStub({
64+
getFile: () => {
65+
storageTouched = true;
66+
return null;
67+
},
68+
});
69+
70+
const result = writeFile(storage, {
71+
path: `/reddit/subreddits/localllama/posts/${segment}.json`,
72+
ifMatch: "*",
73+
content: "{}",
74+
});
75+
76+
expect(result).toEqual({ ok: false, error: "invalid_input" });
77+
expect(storageTouched).toBe(false);
78+
});
79+
80+
it("allows a write with normal-length path segments to proceed past the guard", () => {
81+
const storage = storageStub();
82+
const result = writeFile(storage, {
83+
path: "/reddit/subreddits/localllama/posts/a-normal-title__abc123.json",
84+
ifMatch: "0",
85+
content: "{}",
86+
});
87+
88+
expect(result.ok).toBe(true);
89+
});
90+
});

packages/core/src/files.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export function writeFile(storage: StorageAdapter, req: WriteFileRequest): Write
9494
}
9595

9696
const path = normalizePath(req.path);
97+
if (hasOversizedPathSegment(path)) {
98+
return { ok: false, error: "invalid_input" };
99+
}
97100
const ifMatch = normalizeIfMatch(req.ifMatch);
98101
if (!ifMatch) {
99102
return { ok: false, error: "missing_precondition" };
@@ -274,6 +277,27 @@ export function deleteFile(storage: StorageAdapter, req: DeleteFileRequest): Del
274277
export const DEFAULT_CONTENT_TYPE = "text/markdown";
275278
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
276279

280+
// Local mounts materialize each path segment as a real filesystem entry, and
281+
// most filesystems (ext4, APFS, ...) reject names over 255 bytes with
282+
// ENAMETOOLONG -- worse once a mount's own atomic-write suffix (e.g.
283+
// `.tmp-<random>`) is appended to the leaf segment. A provider adapter that
284+
// builds a path segment from unbounded user content (a long title, etc.) can
285+
// produce a canonical path no mount can ever materialize, which then fails
286+
// forever on every sync cycle since nothing else revisits an already-written
287+
// path. Reject that at write time instead of a bounded content-derived slug
288+
// having to be probabilistically clean everywhere it's used.
289+
export const MAX_PATH_SEGMENT_BYTES = 200;
290+
291+
function encodedByteLength(value: string): number {
292+
return new TextEncoder().encode(value).length;
293+
}
294+
295+
export function hasOversizedPathSegment(path: string): boolean {
296+
return path
297+
.split("/")
298+
.some((segment) => segment.length > 0 && encodedByteLength(segment) > MAX_PATH_SEGMENT_BYTES);
299+
}
300+
277301
function normalizeEncoding(value?: string): "utf-8" | "base64" | null {
278302
const encoding = value?.trim().toLowerCase() ?? "";
279303
if (!encoding || encoding === "utf-8" || encoding === "utf8") {

packages/core/src/webhooks.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,33 @@ describe("webhook Slack path canonicalization", () => {
208208
"/slack/channels/C123/messages/1711111111_000100/meta.json",
209209
);
210210
});
211+
212+
it("rejects an envelope write whose path has an oversized segment instead of writing it", () => {
213+
const storage = new MemoryStorage();
214+
const oversizedSlug = "a".repeat(250);
215+
216+
const queued = ingestWebhook(storage, {
217+
provider: "reddit",
218+
eventType: "file.created",
219+
path: `/reddit/subreddits/localllama/posts/${oversizedSlug}__1uvwn9q.json`,
220+
deliveryId: "delivery_1",
221+
timestamp: "2026-07-15T09:45:00.000Z",
222+
correlationId: "corr_1",
223+
}, {
224+
generateEnvelopeId: () => "env_1",
225+
coalesceWindowMs: 10_000,
226+
});
227+
expect(queued.status).toBe("queued");
228+
229+
const envelope = storage.envelopes.get("env_1");
230+
if (!envelope) throw new Error("envelope not queued");
231+
232+
const result = applyWebhookEnvelope(storage, envelope);
233+
234+
expect(result.status).toBe("rejected");
235+
expect(result.reason).toBe("path_too_long");
236+
expect(storage.files.size).toBe(0);
237+
});
211238
});
212239

213240
function fileRow(path: string, overrides: Partial<FileRow> = {}): FileRow {

packages/core/src/webhooks.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
Paginated,
2222
FileSemantics,
2323
} from "./storage.js";
24-
import { normalizePath, DEFAULT_CONTENT_TYPE, MAX_FILE_BYTES, encodedSize } from "./files.js";
24+
import { normalizePath, DEFAULT_CONTENT_TYPE, MAX_FILE_BYTES, encodedSize, hasOversizedPathSegment } from "./files.js";
2525

2626
export interface IngestWebhookInput {
2727
provider: string;
@@ -397,6 +397,16 @@ export function applyWebhookEnvelope(
397397
};
398398
}
399399

400+
if (hasOversizedPathSegment(event.path)) {
401+
return {
402+
status: "rejected",
403+
eventType: event.type,
404+
path: event.path,
405+
revision: null,
406+
reason: "path_too_long",
407+
};
408+
}
409+
400410
const content =
401411
typeof event.content === "string"
402412
? event.content

0 commit comments

Comments
 (0)