@relayfile/core 0.10.24 → 0.10.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/files.d.ts +2 -0
- package/dist/files.js +21 -0
- package/dist/files.test.d.ts +1 -0
- package/dist/files.test.js +76 -0
- package/dist/webhooks.js +10 -1
- package/dist/webhooks.test.js +23 -0
- package/package.json +1 -1
package/dist/files.d.ts
CHANGED
|
@@ -66,4 +66,6 @@ export declare function readFile(storage: StorageAdapter, path: string): FileRow
|
|
|
66
66
|
export declare function deleteFile(storage: StorageAdapter, req: DeleteFileRequest): DeleteResult;
|
|
67
67
|
export declare const DEFAULT_CONTENT_TYPE = "text/markdown";
|
|
68
68
|
export declare const MAX_FILE_BYTES: number;
|
|
69
|
+
export declare const MAX_PATH_SEGMENT_BYTES = 200;
|
|
70
|
+
export declare function hasOversizedPathSegment(path: string): boolean;
|
|
69
71
|
export declare function encodedSize(content: string, encoding: "utf-8" | "base64"): number;
|
package/dist/files.js
CHANGED
|
@@ -41,6 +41,9 @@ export function writeFile(storage, req) {
|
|
|
41
41
|
return { ok: false, error: "invalid_input" };
|
|
42
42
|
}
|
|
43
43
|
const path = normalizePath(req.path);
|
|
44
|
+
if (hasOversizedPathSegment(path)) {
|
|
45
|
+
return { ok: false, error: "invalid_input" };
|
|
46
|
+
}
|
|
44
47
|
const ifMatch = normalizeIfMatch(req.ifMatch);
|
|
45
48
|
if (!ifMatch) {
|
|
46
49
|
return { ok: false, error: "missing_precondition" };
|
|
@@ -204,6 +207,24 @@ export function deleteFile(storage, req) {
|
|
|
204
207
|
}
|
|
205
208
|
export const DEFAULT_CONTENT_TYPE = "text/markdown";
|
|
206
209
|
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
210
|
+
// Local mounts materialize each path segment as a real filesystem entry, and
|
|
211
|
+
// most filesystems (ext4, APFS, ...) reject names over 255 bytes with
|
|
212
|
+
// ENAMETOOLONG -- worse once a mount's own atomic-write suffix (e.g.
|
|
213
|
+
// `.tmp-<random>`) is appended to the leaf segment. A provider adapter that
|
|
214
|
+
// builds a path segment from unbounded user content (a long title, etc.) can
|
|
215
|
+
// produce a canonical path no mount can ever materialize, which then fails
|
|
216
|
+
// forever on every sync cycle since nothing else revisits an already-written
|
|
217
|
+
// path. Reject that at write time instead of a bounded content-derived slug
|
|
218
|
+
// having to be probabilistically clean everywhere it's used.
|
|
219
|
+
export const MAX_PATH_SEGMENT_BYTES = 200;
|
|
220
|
+
function encodedByteLength(value) {
|
|
221
|
+
return new TextEncoder().encode(value).length;
|
|
222
|
+
}
|
|
223
|
+
export function hasOversizedPathSegment(path) {
|
|
224
|
+
return path
|
|
225
|
+
.split("/")
|
|
226
|
+
.some((segment) => segment.length > 0 && encodedByteLength(segment) > MAX_PATH_SEGMENT_BYTES);
|
|
227
|
+
}
|
|
207
228
|
function normalizeEncoding(value) {
|
|
208
229
|
const encoding = value?.trim().toLowerCase() ?? "";
|
|
209
230
|
if (!encoding || encoding === "utf-8" || encoding === "utf8") {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasOversizedPathSegment, MAX_PATH_SEGMENT_BYTES, writeFile } from "./files.js";
|
|
3
|
+
describe("hasOversizedPathSegment", () => {
|
|
4
|
+
it("accepts a path whose every segment is at the byte limit", () => {
|
|
5
|
+
const segment = "a".repeat(MAX_PATH_SEGMENT_BYTES - ".json".length);
|
|
6
|
+
expect(hasOversizedPathSegment(`/reddit/subreddits/localllama/posts/${segment}.json`)).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
it("rejects a path with one segment over the byte limit", () => {
|
|
9
|
+
const segment = "a".repeat(MAX_PATH_SEGMENT_BYTES + 1);
|
|
10
|
+
expect(hasOversizedPathSegment(`/reddit/subreddits/localllama/posts/${segment}.json`)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
it("counts UTF-8 encoded bytes, not JS string length", () => {
|
|
13
|
+
// Each "é" is 1 UTF-16 code unit but 2 UTF-8 bytes.
|
|
14
|
+
const segment = "é".repeat(MAX_PATH_SEGMENT_BYTES); // 200 chars, 400 bytes
|
|
15
|
+
expect(hasOversizedPathSegment(`/x/${segment}`)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it("ignores empty segments from a normalized path", () => {
|
|
18
|
+
expect(hasOversizedPathSegment("/reddit/subreddits/localllama.json")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it("reproduces the real oversized r/LocalLLaMA post slug that broke the mount", () => {
|
|
21
|
+
const badPath = "/reddit/subreddits/localllama/posts/i-just-don-t-get-it-these-big-tech-companies-can-illegally-" +
|
|
22
|
+
"scrape-the-entire-internet-and-gatekeep-their-better-models-behind-higher-prices-so-it-s-natural-" +
|
|
23
|
+
"that-people-look-for-affordable-options-and-there-will-be-providers-who-apparently-distill-models-" +
|
|
24
|
+
"from-them__1uvwn9q.json";
|
|
25
|
+
expect(hasOversizedPathSegment(badPath)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("writeFile", () => {
|
|
29
|
+
function storageStub(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
getFile: () => null,
|
|
32
|
+
listFiles: () => [],
|
|
33
|
+
putFile: () => { },
|
|
34
|
+
deleteFile: () => { },
|
|
35
|
+
appendEvent: () => { },
|
|
36
|
+
listEvents: () => ({ items: [], nextCursor: null }),
|
|
37
|
+
getRecentEvents: () => [],
|
|
38
|
+
getOperation: () => null,
|
|
39
|
+
putOperation: () => { },
|
|
40
|
+
listOperations: () => ({ items: [], nextCursor: null }),
|
|
41
|
+
nextRevision: () => "rev_1",
|
|
42
|
+
nextOperationId: () => "op_1",
|
|
43
|
+
nextEventId: () => "evt_1",
|
|
44
|
+
enqueueWriteback: () => { },
|
|
45
|
+
getPendingWritebacks: () => [],
|
|
46
|
+
getWorkspaceId: () => "ws_test",
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
it("rejects a write whose path has an oversized segment before touching storage", () => {
|
|
51
|
+
const segment = "a".repeat(MAX_PATH_SEGMENT_BYTES + 1);
|
|
52
|
+
let storageTouched = false;
|
|
53
|
+
const storage = storageStub({
|
|
54
|
+
getFile: () => {
|
|
55
|
+
storageTouched = true;
|
|
56
|
+
return null;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const result = writeFile(storage, {
|
|
60
|
+
path: `/reddit/subreddits/localllama/posts/${segment}.json`,
|
|
61
|
+
ifMatch: "*",
|
|
62
|
+
content: "{}",
|
|
63
|
+
});
|
|
64
|
+
expect(result).toEqual({ ok: false, error: "invalid_input" });
|
|
65
|
+
expect(storageTouched).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
it("allows a write with normal-length path segments to proceed past the guard", () => {
|
|
68
|
+
const storage = storageStub();
|
|
69
|
+
const result = writeFile(storage, {
|
|
70
|
+
path: "/reddit/subreddits/localllama/posts/a-normal-title__abc123.json",
|
|
71
|
+
ifMatch: "0",
|
|
72
|
+
content: "{}",
|
|
73
|
+
});
|
|
74
|
+
expect(result.ok).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
package/dist/webhooks.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* This module stays pure. It only operates over plain storage records and
|
|
14
14
|
* optional callbacks for suppression/staleness policy.
|
|
15
15
|
*/
|
|
16
|
-
import { normalizePath, DEFAULT_CONTENT_TYPE, MAX_FILE_BYTES, encodedSize } from "./files.js";
|
|
16
|
+
import { normalizePath, DEFAULT_CONTENT_TYPE, MAX_FILE_BYTES, encodedSize, hasOversizedPathSegment } from "./files.js";
|
|
17
17
|
export function ingestWebhook(storage, input, options = {}) {
|
|
18
18
|
const envelopeStorage = getWebhookStorage(storage);
|
|
19
19
|
const correlationId = input.correlationId?.trim() ?? "";
|
|
@@ -226,6 +226,15 @@ export function applyWebhookEnvelope(storage, envelope, options = {}) {
|
|
|
226
226
|
revision: null,
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
|
+
if (hasOversizedPathSegment(event.path)) {
|
|
230
|
+
return {
|
|
231
|
+
status: "rejected",
|
|
232
|
+
eventType: event.type,
|
|
233
|
+
path: event.path,
|
|
234
|
+
revision: null,
|
|
235
|
+
reason: "path_too_long",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
229
238
|
const content = typeof event.content === "string"
|
|
230
239
|
? event.content
|
|
231
240
|
: JSON.stringify(event.data ?? {});
|
package/dist/webhooks.test.js
CHANGED
|
@@ -168,6 +168,29 @@ describe("webhook Slack path canonicalization", () => {
|
|
|
168
168
|
expect(envelope?.deliveryIds).toEqual(["delivery_1", "delivery_2"]);
|
|
169
169
|
expect(envelope?.payload.path).toBe("/slack/channels/C123/messages/1711111111_000100/meta.json");
|
|
170
170
|
});
|
|
171
|
+
it("rejects an envelope write whose path has an oversized segment instead of writing it", () => {
|
|
172
|
+
const storage = new MemoryStorage();
|
|
173
|
+
const oversizedSlug = "a".repeat(250);
|
|
174
|
+
const queued = ingestWebhook(storage, {
|
|
175
|
+
provider: "reddit",
|
|
176
|
+
eventType: "file.created",
|
|
177
|
+
path: `/reddit/subreddits/localllama/posts/${oversizedSlug}__1uvwn9q.json`,
|
|
178
|
+
deliveryId: "delivery_1",
|
|
179
|
+
timestamp: "2026-07-15T09:45:00.000Z",
|
|
180
|
+
correlationId: "corr_1",
|
|
181
|
+
}, {
|
|
182
|
+
generateEnvelopeId: () => "env_1",
|
|
183
|
+
coalesceWindowMs: 10_000,
|
|
184
|
+
});
|
|
185
|
+
expect(queued.status).toBe("queued");
|
|
186
|
+
const envelope = storage.envelopes.get("env_1");
|
|
187
|
+
if (!envelope)
|
|
188
|
+
throw new Error("envelope not queued");
|
|
189
|
+
const result = applyWebhookEnvelope(storage, envelope);
|
|
190
|
+
expect(result.status).toBe("rejected");
|
|
191
|
+
expect(result.reason).toBe("path_too_long");
|
|
192
|
+
expect(storage.files.size).toBe(0);
|
|
193
|
+
});
|
|
171
194
|
});
|
|
172
195
|
function fileRow(path, overrides = {}) {
|
|
173
196
|
return {
|
package/package.json
CHANGED