@crewhaus/citation-tracker 0.1.0 → 0.1.2
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/package.json +6 -11
- package/src/index.test.ts +152 -1
- package/src/index.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/citation-tracker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Per-research-run citation persistence + URL dedup (Section 23 RES)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { CitationTrackerError, createCitationTracker, newRunId } from "./index.js";
|
|
@@ -136,4 +136,155 @@ describe("createCitationTracker", () => {
|
|
|
136
136
|
rmSync(root, { recursive: true, force: true });
|
|
137
137
|
}
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
test("malformed jsonl in citations.jsonl surfaces as a CitationTrackerError", () => {
|
|
141
|
+
const root = newRoot();
|
|
142
|
+
const runId = newRunId();
|
|
143
|
+
const path = join(root, runId, "citations.jsonl");
|
|
144
|
+
try {
|
|
145
|
+
const t = createCitationTracker({ runId, rootDir: root });
|
|
146
|
+
t.recordCitation({ url: "u", snippet: "s" });
|
|
147
|
+
appendFileSync(path, "{not valid json\n");
|
|
148
|
+
expect(() => createCitationTracker({ runId, rootDir: root })).toThrow(CitationTrackerError);
|
|
149
|
+
} finally {
|
|
150
|
+
rmSync(root, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("getFetchRecord returns metadata for a known URL and undefined otherwise", () => {
|
|
155
|
+
const root = newRoot();
|
|
156
|
+
try {
|
|
157
|
+
const t = createCitationTracker({ rootDir: root });
|
|
158
|
+
expect(t.getFetchRecord("missing")).toBeUndefined();
|
|
159
|
+
const rec = t.recordFetch({ url: "u", content: "the body", branchId: "b7" });
|
|
160
|
+
const got = t.getFetchRecord("u");
|
|
161
|
+
expect(got).toEqual(rec);
|
|
162
|
+
expect(got?.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
163
|
+
expect(got?.contentBytes).toBe(Buffer.byteLength("the body", "utf8"));
|
|
164
|
+
expect(got?.branchId).toBe("b7");
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("getFetchedContent returns undefined for an unknown URL", () => {
|
|
171
|
+
const root = newRoot();
|
|
172
|
+
try {
|
|
173
|
+
const t = createCitationTracker({ rootDir: root });
|
|
174
|
+
expect(t.getFetchedContent("never-fetched")).toBeUndefined();
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(root, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("listFetches returns all fetch records in append order", () => {
|
|
181
|
+
const root = newRoot();
|
|
182
|
+
try {
|
|
183
|
+
const t = createCitationTracker({ rootDir: root });
|
|
184
|
+
expect(t.listFetches()).toEqual([]);
|
|
185
|
+
t.recordFetch({ url: "first", content: "1" });
|
|
186
|
+
t.recordFetch({ url: "second", content: "2" });
|
|
187
|
+
// Idempotent re-record must NOT add a second entry.
|
|
188
|
+
t.recordFetch({ url: "first", content: "1-again" });
|
|
189
|
+
const urls = t.listFetches().map((r) => r.url);
|
|
190
|
+
expect(urls).toEqual(["first", "second"]);
|
|
191
|
+
} finally {
|
|
192
|
+
rmSync(root, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("listFetches survives kill-and-resume in append order", () => {
|
|
197
|
+
const root = newRoot();
|
|
198
|
+
const runId = newRunId();
|
|
199
|
+
try {
|
|
200
|
+
const t1 = createCitationTracker({ runId, rootDir: root });
|
|
201
|
+
t1.recordFetch({ url: "a", content: "ka" });
|
|
202
|
+
t1.recordFetch({ url: "b", content: "kb" });
|
|
203
|
+
const t2 = createCitationTracker({ runId, rootDir: root });
|
|
204
|
+
expect(t2.listFetches().map((r) => r.url)).toEqual(["a", "b"]);
|
|
205
|
+
} finally {
|
|
206
|
+
rmSync(root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("recordCitation carries branchId and supportingClaim through to disk", () => {
|
|
211
|
+
const root = newRoot();
|
|
212
|
+
const runId = newRunId();
|
|
213
|
+
try {
|
|
214
|
+
const t = createCitationTracker({ runId, rootDir: root });
|
|
215
|
+
const c = t.recordCitation({
|
|
216
|
+
url: "u",
|
|
217
|
+
snippet: "s",
|
|
218
|
+
branchId: "branch-9",
|
|
219
|
+
supportingClaim: "supports: target shapes list",
|
|
220
|
+
});
|
|
221
|
+
expect(c.branchId).toBe("branch-9");
|
|
222
|
+
expect(c.supportingClaim).toBe("supports: target shapes list");
|
|
223
|
+
|
|
224
|
+
// Round-trips verbatim through the JSONL on resume.
|
|
225
|
+
const reopened = createCitationTracker({ runId, rootDir: root });
|
|
226
|
+
const persisted = reopened.listCitationsOrdered()[0];
|
|
227
|
+
expect(persisted?.branchId).toBe("branch-9");
|
|
228
|
+
expect(persisted?.supportingClaim).toBe("supports: target shapes list");
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(root, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("now() injection makes retrievedAt deterministic", () => {
|
|
235
|
+
const root = newRoot();
|
|
236
|
+
try {
|
|
237
|
+
const fixed = new Date("2030-01-02T03:04:05.000Z");
|
|
238
|
+
const t = createCitationTracker({ rootDir: root, now: () => fixed });
|
|
239
|
+
const f = t.recordFetch({ url: "u", content: "x" });
|
|
240
|
+
expect(f.retrievedAt).toBe("2030-01-02T03:04:05.000Z");
|
|
241
|
+
// Citation with no prior fetch also stamps via now().
|
|
242
|
+
const c = t.recordCitation({ url: "other", snippet: "s" });
|
|
243
|
+
expect(c.retrievedAt).toBe("2030-01-02T03:04:05.000Z");
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(root, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("newRunId produces well-formed, accepted run ids", () => {
|
|
250
|
+
const root = newRoot();
|
|
251
|
+
try {
|
|
252
|
+
const id = newRunId();
|
|
253
|
+
expect(id).toMatch(/^run_[0-9a-f]{16}$/);
|
|
254
|
+
// A freshly-minted id must be accepted by the validator.
|
|
255
|
+
const t = createCitationTracker({ runId: id, rootDir: root });
|
|
256
|
+
expect(t.runId).toBe(id);
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("security: a tampered sha256 in fetches.jsonl cannot traverse out of the cache dir", () => {
|
|
263
|
+
const root = newRoot();
|
|
264
|
+
const runId = newRunId();
|
|
265
|
+
try {
|
|
266
|
+
// Boot once to create the run dir, then overwrite fetches.jsonl with a
|
|
267
|
+
// record whose sha256 is a path-traversal payload. A secret file lives
|
|
268
|
+
// two levels up (the runId dir) at a guessable name.
|
|
269
|
+
createCitationTracker({ runId, rootDir: root });
|
|
270
|
+
const secretPath = join(root, runId, "stolen.txt");
|
|
271
|
+
writeFileSync(secretPath, "TOP SECRET CONTENTS");
|
|
272
|
+
// cache/<sha256>.txt -> cache/../stolen.txt resolves to the secret file.
|
|
273
|
+
const evilRecord = {
|
|
274
|
+
version: 1,
|
|
275
|
+
url: "https://victim.example/x",
|
|
276
|
+
retrievedAt: "2030-01-01T00:00:00.000Z",
|
|
277
|
+
sha256: "../stolen",
|
|
278
|
+
contentBytes: 19,
|
|
279
|
+
};
|
|
280
|
+
writeFileSync(join(root, runId, "fetches.jsonl"), `${JSON.stringify(evilRecord)}\n`);
|
|
281
|
+
|
|
282
|
+
const t = createCitationTracker({ runId, rootDir: root });
|
|
283
|
+
expect(t.hasFetched("https://victim.example/x")).toBe(true);
|
|
284
|
+
// The guard must refuse the malformed sha256 rather than read the secret.
|
|
285
|
+
expect(t.getFetchedContent("https://victim.example/x")).toBeUndefined();
|
|
286
|
+
} finally {
|
|
287
|
+
rmSync(root, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
139
290
|
});
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,14 @@ import { CrewhausError, RuntimeError } from "@crewhaus/errors";
|
|
|
35
35
|
|
|
36
36
|
export const DEFAULT_ROOT_DIR = ".crewhaus/research";
|
|
37
37
|
const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
|
|
38
|
+
/**
|
|
39
|
+
* A sha256 digest is always exactly 64 lowercase hex chars. We re-validate it
|
|
40
|
+
* before interpolating into the cache file path because the value can originate
|
|
41
|
+
* from a JSONL line read off disk (boot-time replay), and a corrupted or
|
|
42
|
+
* tampered `fetches.jsonl` must not be able to steer a read outside `cache/`
|
|
43
|
+
* (e.g. a `sha256` of "../../../etc/passwd"). Legitimate records always match.
|
|
44
|
+
*/
|
|
45
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
38
46
|
|
|
39
47
|
export type RunId = string;
|
|
40
48
|
|
|
@@ -170,6 +178,9 @@ export function createCitationTracker(opts: CreateCitationTrackerOptions = {}):
|
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
function tryReadCache(rec: FetchRecord): string | undefined {
|
|
181
|
+
// Defend the cache read against a tampered fetches.jsonl: a sha256 that is
|
|
182
|
+
// not a clean 64-hex digest could otherwise traverse out of cache/.
|
|
183
|
+
if (typeof rec.sha256 !== "string" || !SHA256_RE.test(rec.sha256)) return undefined;
|
|
173
184
|
const p = cachePathFor(rec.sha256);
|
|
174
185
|
if (!existsSync(p)) return undefined;
|
|
175
186
|
return readFileSync(p, "utf8");
|