deepdebug-local-agent 1.5.0 → 1.7.0
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/docs/la-protocol-v2.md +70 -16
- package/env.example +6 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/config.js +10 -2
- package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
- package/src/la-protocol-v2/content-handlers/egress.js +22 -0
- package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
- package/src/la-protocol-v2/content-handlers/handler.js +52 -16
- package/src/la-protocol-v2/content-handlers/install.js +35 -15
- package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
- package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +87 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/contract.js +9 -0
- package/src/la-protocol-v2/runtime.js +12 -2
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
- package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
- package/test/la-protocol-v2/content-alignment.test.js +669 -0
- package/test/la-protocol-v2/content-deny-list.test.js +138 -0
- package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
- package/test/la-protocol-v2/content-file-list.test.js +385 -0
- package/test/la-protocol-v2/content-handlers.test.js +55 -61
- package/test/la-protocol-v2/content-path-guard.test.js +10 -5
- package/test/la-protocol-v2/content-wiring.test.js +39 -14
- package/test/la-protocol-v2/contract.test.js +15 -5
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
- package/tools/interop/README.md +16 -0
- package/tools/interop/la-interop-content-client.mjs +19 -8
- package/tools/interop/la-interop-list-client.mjs +93 -0
|
@@ -2,16 +2,34 @@
|
|
|
2
2
|
* git-diff.js - `diff` kind: the work tree against HEAD, for the files that may be shown (F3-07).
|
|
3
3
|
*
|
|
4
4
|
* No shell. git runs with the agent's env minus secret-looking variables and minus every GIT_* variable (a stray
|
|
5
|
-
* GIT_DIR must not point it at another repository), without external diff drivers, textconv
|
|
6
|
-
*
|
|
7
|
-
* first and filtered (sensitive and
|
|
8
|
-
* (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the
|
|
5
|
+
* GIT_DIR must not point it at another repository), without external diff drivers, textconv or fsmonitor hooks
|
|
6
|
+
* (a malicious repo config could name a command for each), with literal pathspecs. The file list is taken
|
|
7
|
+
* first and filtered (sensitive names, and hard-linked files unless allowed, never reach the second call), then diffed
|
|
8
|
+
* with that explicit list (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the
|
|
9
|
+
* process is killed above the cap).
|
|
10
|
+
*
|
|
11
|
+
* Clean/smudge filters (F3-07b). `--no-ext-diff` and `--no-textconv` do NOT stop `git diff` from running the
|
|
12
|
+
* `filter.<name>.clean` command (and `process`, the long-running filter) of the repository config: to compare a work tree file with
|
|
13
|
+
* the index git first converts it with the clean filter its .gitattributes names, and that is a command line chosen
|
|
14
|
+
* by whoever wrote the repository. Before the two diff calls the filters the config defines are listed (`git config
|
|
15
|
+
* --get-regexp '^filter\.'`, which runs nothing) and every one of them is overridden on the command line with an empty
|
|
16
|
+
* clean, smudge and process and `required=false` (git treats an empty command as "no filter"; a still-required one
|
|
17
|
+
* would abort). Fail closed: if the list cannot be read, or has more than MAX_FILTERS names, there is no diff.
|
|
18
|
+
* Consequence: a work tree that depends on a filter (git-lfs, keyword expansion) is diffed as stored, unfiltered.
|
|
19
|
+
*
|
|
20
|
+
* Symlinks (F3-07c). The hunk of a tracked symlink ("new file mode 120000", "+/abs/target") prints the TARGET of the link, i.e. an
|
|
21
|
+
* absolute path on this machine. Entries whose mode is 120000 on either side (git diff --raw: a link added, changed, removed, or a file
|
|
22
|
+
* replaced by a link and the other way round) never reach the second call; the names are also lstat-ed (a file turned into a link in
|
|
23
|
+
* between) and, as a last net, `dropSymlinkBlocks()` removes from the text any per-file block whose header says 120000.
|
|
9
24
|
*/
|
|
10
25
|
import { spawn } from "node:child_process";
|
|
11
26
|
import fs from "node:fs";
|
|
12
27
|
import { sanitizedChildEnv } from "../../utils/child-env.js";
|
|
28
|
+
import path from "node:path";
|
|
13
29
|
import { isSensitivePath, normalizeRel } from "./path-guard.js";
|
|
14
30
|
|
|
31
|
+
const MAX_FILTERS = 64;
|
|
32
|
+
|
|
15
33
|
const GIT_SAFE = ["--literal-pathspecs", "-c", "core.quotepath=off", "-c", "core.fsmonitor=false", "-c", "core.pager=cat", "-c", "diff.external="];
|
|
16
34
|
|
|
17
35
|
function gitEnv() {
|
|
@@ -53,11 +71,36 @@ function runGit(args, { cwd, input, maxBytes, timeoutMs, signal }) {
|
|
|
53
71
|
});
|
|
54
72
|
}
|
|
55
73
|
|
|
74
|
+
/**
|
|
75
|
+
* `-c` overrides that switch off every filter the repository (or the user's global config) defines.
|
|
76
|
+
* @returns {Promise<{ok:true, args:string[]} | {ok:false, status:"error", errorCode:string}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function filterOverrides({ cwd, timeoutMs, signal }) {
|
|
79
|
+
const r = await runGit([...GIT_SAFE, "config", "--null", "--get-regexp", "^filter\\."], { cwd, maxBytes: 1024 * 1024, timeoutMs, signal });
|
|
80
|
+
if (r.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
81
|
+
if (r.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
82
|
+
if (r.code === 1 && !r.overflow) return { ok: true, args: [] }; // exit 1 = no such key: no filter is defined
|
|
83
|
+
if (r.code !== 0 || r.overflow) return { ok: false, status: "error", errorCode: "internal" };
|
|
84
|
+
const names = new Set();
|
|
85
|
+
for (const entry of r.out.toString("utf8").split("\0")) {
|
|
86
|
+
const key = entry.split("\n", 1)[0]; // "filter.<name>.<variable>" (name may contain dots)
|
|
87
|
+
if (!key.toLowerCase().startsWith("filter.")) continue;
|
|
88
|
+
const last = key.lastIndexOf(".");
|
|
89
|
+
if (last <= "filter.".length) continue; // "filter.x": a variable without a name, nothing to run
|
|
90
|
+
names.add(key.slice("filter.".length, last));
|
|
91
|
+
}
|
|
92
|
+
if (names.size > MAX_FILTERS) return { ok: false, status: "error", errorCode: "internal" };
|
|
93
|
+
const args = [];
|
|
94
|
+
for (const n of names) for (const v of ["clean", "smudge", "process"]) args.push("-c", `filter.${n}.${v}=`);
|
|
95
|
+
for (const n of names) args.push("-c", `filter.${n}.required=false`);
|
|
96
|
+
return { ok: true, args };
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
/**
|
|
57
100
|
* @param {{root:string, rel?:string|null, maxBytes:number, maxFiles:number, timeoutMs:number, denyGlobs?:string[], signal?:AbortSignal}} o
|
|
58
101
|
* @returns {Promise<{ok:true, text:string} | {ok:false, status:"not_found"|"forbidden"|"error", errorCode?:string}>}
|
|
59
102
|
*/
|
|
60
|
-
export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], signal }) {
|
|
103
|
+
export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], allowHardlinks = false, signal }) {
|
|
61
104
|
let cwd;
|
|
62
105
|
try { cwd = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
|
|
63
106
|
let only = null;
|
|
@@ -66,7 +109,10 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
|
|
|
66
109
|
if (!only) return { ok: false, status: "not_found" };
|
|
67
110
|
if (isSensitivePath(only, denyGlobs)) return { ok: false, status: "forbidden" };
|
|
68
111
|
}
|
|
69
|
-
const
|
|
112
|
+
const filters = await filterOverrides({ cwd, timeoutMs, signal });
|
|
113
|
+
if (!filters.ok) return filters;
|
|
114
|
+
const safe = [...GIT_SAFE, ...filters.args];
|
|
115
|
+
const listArgs = [...safe, "diff", "--raw", "-z", "--no-abbrev", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
|
|
70
116
|
const listed = await runGit(listArgs, { cwd, maxBytes: 4 * 1024 * 1024, timeoutMs, signal });
|
|
71
117
|
if (listed.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
72
118
|
if (listed.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
@@ -74,19 +120,58 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
|
|
|
74
120
|
if (listed.code !== 0) return { ok: false, status: "not_found" }; // not a repository, no HEAD yet, unsafe directory...
|
|
75
121
|
const names = [];
|
|
76
122
|
let argBytes = 0;
|
|
77
|
-
for (const n of listed.out.toString("utf8")
|
|
78
|
-
|
|
123
|
+
for (const n of rawPaths(listed.out.toString("utf8"))) {
|
|
124
|
+
if (n.symlink) continue; // the hunk would print the target of the link
|
|
125
|
+
const norm = n.path ? normalizeRel(n.path) : null;
|
|
79
126
|
if (!norm || isSensitivePath(norm, denyGlobs)) continue;
|
|
127
|
+
if (await isLinkOnDisk(cwd, norm, allowHardlinks)) continue; // a link (or a hard link) now, whatever git remembers
|
|
80
128
|
argBytes += Buffer.byteLength(norm) + 1;
|
|
81
129
|
if (names.length >= maxFiles || argBytes > 128 * 1024) break;
|
|
82
130
|
names.push(norm);
|
|
83
131
|
}
|
|
84
132
|
if (names.length === 0) return { ok: true, text: "" };
|
|
85
|
-
const diffArgs = [...
|
|
133
|
+
const diffArgs = [...safe, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--relative", "-U3", "HEAD", "--", ...names];
|
|
86
134
|
const diff = await runGit(diffArgs, { cwd, maxBytes: maxBytes + 1, timeoutMs, signal });
|
|
87
135
|
if (diff.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
88
136
|
if (diff.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
89
137
|
if (diff.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
|
|
90
138
|
if (diff.code !== 0) return { ok: false, status: "error", errorCode: "internal" };
|
|
91
|
-
return { ok: true, text: diff.out.toString("utf8") };
|
|
139
|
+
return { ok: true, text: dropSymlinkBlocks(diff.out.toString("utf8")) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `git diff --raw -z` -> [{path, symlink}]: ":<oldmode> <newmode> <oldsha> <newsha> <status>\0<path>\0" per entry (no renames: one path).
|
|
144
|
+
* @returns {{path:string, symlink:boolean}[]}
|
|
145
|
+
*/
|
|
146
|
+
export function rawPaths(out) {
|
|
147
|
+
const parts = out.split("\0");
|
|
148
|
+
const res = [];
|
|
149
|
+
for (let i = 0; i < parts.length; i++) {
|
|
150
|
+
const meta = parts[i];
|
|
151
|
+
if (!meta.startsWith(":")) continue;
|
|
152
|
+
const f = meta.slice(1).split(" ");
|
|
153
|
+
const p = parts[i + 1];
|
|
154
|
+
i++;
|
|
155
|
+
if (typeof p !== "string") break;
|
|
156
|
+
res.push({ path: p, symlink: f[0] === "120000" || f[1] === "120000" });
|
|
157
|
+
}
|
|
158
|
+
return res;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Removes the per-file blocks of a diff whose header (up to the first hunk) names mode 120000 (a symlink): they print the link target. */
|
|
162
|
+
export function dropSymlinkBlocks(text) {
|
|
163
|
+
if (!text.includes("120000")) return text;
|
|
164
|
+
const blocks = text.split(/^(?=diff --git )/m);
|
|
165
|
+
return blocks.filter((b) => {
|
|
166
|
+
const head = b.split(/^@@/m, 1)[0];
|
|
167
|
+
return !/^(?:(?:new file|deleted file|old|new) mode 120000|index [0-9a-f]+\.\.[0-9a-f]+ 120000)\s*$/m.test(head);
|
|
168
|
+
}).join("");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True for a symlink, or (unless allowed) a regular file with more than one link. A name that no longer exists (a deletion) is neither. */
|
|
172
|
+
async function isLinkOnDisk(cwd, rel, allowHardlinks) {
|
|
173
|
+
try {
|
|
174
|
+
const st = await fs.promises.lstat(path.join(cwd, ...rel.split("/")));
|
|
175
|
+
return st.isSymbolicLink() || (!allowHardlinks && st.isFile() && st.nlink > 1);
|
|
176
|
+
} catch { return false; }
|
|
92
177
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 1. accept: the handler answers the session at once ({accept:true} = ack `accepted`); the work goes on in the
|
|
7
7
|
* background (the first chunk may take seconds, the ack timeout is 5 s). A request without requestId is refused.
|
|
8
8
|
* 2. admission: at most `maxConcurrent` requests and `maxInFlightBytes` in memory; above it -> `rate_limited`.
|
|
9
|
-
* 3. kind: `file` and `
|
|
9
|
+
* 3. kind: `file`, `diff` and `file.list` (F3-07c: a folder listing that mints the ids of what it shows) are served; every other kind of the allowlist -> status `error` + `protocol_unsupported`
|
|
10
10
|
* (the contract has no "not supported" status; this is the code already used for "no handler").
|
|
11
11
|
* 4. workspace: the envelope's workspace must be registered here (and belong to this tenant) -> else `not_found`.
|
|
12
12
|
* 5. ref: an opaque id (ref-registry.js) valid for this tenant+workspace+root+kind -> else `not_found`, whatever the
|
|
@@ -22,15 +22,19 @@ import { ProtocolError } from "../errors.js";
|
|
|
22
22
|
import { sendContent, sendContentFailure } from "../content.js";
|
|
23
23
|
import { REF_KINDS } from "./ref-registry.js";
|
|
24
24
|
import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
|
|
25
|
+
import { listFolder, ROOT_REF, clampLimit, MAX_LIST_BYTES } from "./file-list.js";
|
|
25
26
|
import { worktreeDiff } from "./git-diff.js";
|
|
26
|
-
import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
|
|
27
|
+
import { prepareOutgoing, prepareList, maskName, contentTypeOf, decodeText } from "./egress.js";
|
|
27
28
|
import { createAudit, short } from "./audit.js";
|
|
29
|
+
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
28
30
|
|
|
29
|
-
export const SERVED_KINDS = Object.freeze(["file", "diff"]);
|
|
31
|
+
export const SERVED_KINDS = Object.freeze(["file", "diff", "file.list"]);
|
|
30
32
|
/** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
|
|
31
33
|
export const WORKTREE_REF = "worktree";
|
|
32
34
|
|
|
33
|
-
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now }) {
|
|
35
|
+
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now, mint = null }) {
|
|
36
|
+
// F3-07c: where the ids of a listing come from. install.js passes mintRef (registry + content.index); without it (tests) the registry alone.
|
|
37
|
+
const mintFor = (workspaceId, root) => (o) => (mint ? mint({ workspaceId, ...o }) : registry.mint({ workspaceId, root, ...o }));
|
|
34
38
|
const inflight = new Map(); // requestId -> { ac, timer }
|
|
35
39
|
let reservedBytes = 0;
|
|
36
40
|
const auditor = audit || createAudit({ file: config.auditFile, now });
|
|
@@ -43,8 +47,12 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
43
47
|
/** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
|
|
44
48
|
async function produce(ctx, p, signal) {
|
|
45
49
|
if (!SERVED_KINDS.includes(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
|
|
50
|
+
// F3-07b: content never leaves unmasked. runtime.js does not even register this handler without DLP enforce; this is the
|
|
51
|
+
// same rule at the moment of the request (the env is read per request), so a changed mode can not open the door.
|
|
52
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
|
|
46
53
|
const ws = await resolveWorkspace(ctx.workspaceId);
|
|
47
54
|
if (!ws || !ws.root) return { status: "not_found" };
|
|
55
|
+
if (p.kind === "file.list") return produceList(ctx, p, ws, signal);
|
|
48
56
|
const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
|
|
49
57
|
if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
|
|
50
58
|
if (!target) return { status: "not_found" };
|
|
@@ -55,18 +63,38 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
55
63
|
async function produceFile(p, ws, rel, signal) {
|
|
56
64
|
const norm = rel && normalizeRel(rel);
|
|
57
65
|
if (!norm) return { status: "not_found" };
|
|
58
|
-
const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs);
|
|
66
|
+
const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs, { allowHardlinks: config.allowHardlinks });
|
|
59
67
|
if (!resolved.ok) return { status: resolved.status };
|
|
60
68
|
if (signal.aborted) return { status: "error", errorCode: "agent_offline" };
|
|
61
|
-
const read = await readRegular(resolved, config.maxBytes, signal);
|
|
69
|
+
const read = await readRegular(resolved, config.maxBytes, signal, { allowHardlinks: config.allowHardlinks });
|
|
62
70
|
if (!read.ok) return read.status === "too_large" ? { status: "error", errorCode: "payload_too_large" } : { status: read.status };
|
|
63
71
|
const isText = decodeText(read.buffer) !== null;
|
|
64
72
|
return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
/** F3-07c: `root` or a folder id (or the nextRef of a previous page) -> one page of names with the ids to ask for them. */
|
|
76
|
+
async function produceList(ctx, p, ws, signal) {
|
|
77
|
+
if (p.range) return { status: "error", errorCode: "protocol_unsupported" }; // a listing has no byte range
|
|
78
|
+
let rel = null;
|
|
79
|
+
let after = null;
|
|
80
|
+
if (p.ref !== ROOT_REF) {
|
|
81
|
+
const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: "file.list" });
|
|
82
|
+
if (!target) return { status: "not_found" };
|
|
83
|
+
rel = target.rel && normalizeRel(target.rel);
|
|
84
|
+
if (target.rel && !rel) return { status: "not_found" };
|
|
85
|
+
after = target.after;
|
|
86
|
+
}
|
|
87
|
+
const r = await listFolder({ root: ws.root, rel, after, limit: clampLimit(p.limit), maxBytes: config.maxBytes, maxDirEntries: config.maxDirEntries,
|
|
88
|
+
denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal, mint: mintFor(ctx.workspaceId, ws.root),
|
|
89
|
+
maskName: (n) => maskName(n, { env }) });
|
|
90
|
+
if (!r.ok) return r;
|
|
91
|
+
const out = prepareList({ buffer: r.body, maxBytes: Math.min(config.maxBytes, MAX_LIST_BYTES), dlp: { mode: "enforce", total: r.dlp.total, redactions: r.dlp.redactions } });
|
|
92
|
+
return out.ok ? { status: "ok", buffer: out.buffer, contentType: out.contentType, dlp: out.dlp } : { status: out.status, errorCode: out.errorCode, dlp: out.dlp };
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
async function produceDiff(p, ws, rel, signal) {
|
|
68
96
|
const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
|
|
69
|
-
timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, signal });
|
|
97
|
+
timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal });
|
|
70
98
|
if (!d.ok) return d;
|
|
71
99
|
return finish(p, Buffer.from(d.text, "utf8"), "text/x-diff", "content.diff");
|
|
72
100
|
}
|
|
@@ -79,7 +107,9 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
79
107
|
|
|
80
108
|
async function run(ctx, p, entry) {
|
|
81
109
|
const t0 = now();
|
|
82
|
-
|
|
110
|
+
// F3-07b: always defined. If produce() throws, the gateway still gets an answer (a code, never the message of the
|
|
111
|
+
// exception: it may carry a path) and the request is audited like any other.
|
|
112
|
+
let result = { status: "error", errorCode: "internal" };
|
|
83
113
|
let sent = null;
|
|
84
114
|
let errorCode;
|
|
85
115
|
try {
|
|
@@ -88,7 +118,11 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
88
118
|
} else {
|
|
89
119
|
reservedBytes += reserveEach;
|
|
90
120
|
entry.reserved = true;
|
|
91
|
-
|
|
121
|
+
try {
|
|
122
|
+
result = await produce(ctx, p, entry.ac.signal);
|
|
123
|
+
} catch {
|
|
124
|
+
result = { status: "error", errorCode: "internal" };
|
|
125
|
+
}
|
|
92
126
|
}
|
|
93
127
|
if (entry.ac.signal.aborted) {
|
|
94
128
|
errorCode = "cancelled";
|
|
@@ -122,12 +156,14 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
122
156
|
else if (status === "cancelled" || status === "timeout") stats.cancelled++;
|
|
123
157
|
else stats.error++;
|
|
124
158
|
bump(errorCode || result.errorCode || status);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
159
|
+
try {
|
|
160
|
+
auditor.record({
|
|
161
|
+
requestId: ctx.requestId, workspaceId: ctx.workspaceId, kind: p.kind, status, errorCode: errorCode || result.errorCode || null,
|
|
162
|
+
bytes: sent ? sent.bytes : 0, chunks: sent ? sent.chunks : 0, ms: now() - t0,
|
|
163
|
+
dlpMode: result.dlp ? result.dlp.mode : null, dlpHits: result.dlp ? result.dlp.total : 0, dlpRules: result.dlp ? result.dlp.redactions : {},
|
|
164
|
+
refSha: short(p.ref), actorRole: p.actor && p.actor.role, actorSha: short(p.actor && p.actor.userId),
|
|
165
|
+
});
|
|
166
|
+
} catch { /* the audit must never take the data path down (nor leave a request without its answer) */ }
|
|
131
167
|
if (status === "send_failed") warn(`[la-content] ${p.kind} not delivered: ${errorCode}`);
|
|
132
168
|
}
|
|
133
169
|
|
|
@@ -147,7 +183,7 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
147
183
|
entry.timer = setTimeout(() => { entry.reason = "deadline"; entry.ac.abort(); }, config.totalMs);
|
|
148
184
|
inflight.set(ctx.requestId, entry);
|
|
149
185
|
// background: ack first, answer later
|
|
150
|
-
queueMicrotask(() => { run(ctx, p, entry).catch(() =>
|
|
186
|
+
queueMicrotask(() => { run(ctx, p, entry).catch(() => warn("[la-content] request failed internally")); });
|
|
151
187
|
return { accept: true };
|
|
152
188
|
},
|
|
153
189
|
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* install.js - puts the content handlers on a v2 link (F3-07). Called by runtime.js only when
|
|
3
|
-
* LOCAL_AGENT_CONTENT_HANDLERS=true
|
|
2
|
+
* install.js - puts the content handlers on a v2 link (F3-07, F3-07b). Called by runtime.js only when
|
|
3
|
+
* LOCAL_AGENT_CONTENT_HANDLERS=true, v2 is on AND LOCAL_AGENT_DLP_MODE=enforce. Returns what the rest of the agent needs:
|
|
4
4
|
*
|
|
5
|
-
* mintRef({workspaceId, kind, rel})
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* mintRef({workspaceId, kind, rel, size?, after?}) returns {id, expiresAt} for that item (id = 43 base64url chars), or null
|
|
6
|
+
* (sensitive path, unknown workspace). The id is also announced to the control plane in a
|
|
7
|
+
* `content.index` frame ({kind, ref, size}: no name, no path). F3-07c: the real caller is the
|
|
8
|
+
* `file.list` handler (handler.js / file-list.js), which mints one id per entry it lists;
|
|
9
|
+
* `announce()` re-sends what is live.
|
|
9
10
|
*/
|
|
10
11
|
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
11
12
|
import { ContentRefRegistry } from "./ref-registry.js";
|
|
12
13
|
import { createContentHandler, SERVED_KINDS } from "./handler.js";
|
|
14
|
+
import { createContentIndexer } from "./content-index.js";
|
|
15
|
+
import { sizeInsideSync } from "./path-guard.js";
|
|
13
16
|
|
|
14
17
|
export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
|
|
15
18
|
|
|
16
19
|
export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
|
|
20
|
+
// F3-07b, defence in depth: runtime.js already refuses; nobody can install the handlers without DLP enforce
|
|
21
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("content handlers need LOCAL_AGENT_DLP_MODE=enforce");
|
|
17
22
|
const tenant = String(tenantId).toLowerCase();
|
|
18
23
|
const registry = new ContentRefRegistry({ tenantId: tenant, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs });
|
|
19
24
|
|
|
@@ -29,22 +34,37 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
|
|
|
29
34
|
return null;
|
|
30
35
|
};
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
// `indexer` is created below (it needs the session only); mintRef and the handler use it through this binding
|
|
38
|
+
let indexer = null;
|
|
39
|
+
const mintRef = ({ workspaceId, kind, rel, size, after }) => {
|
|
40
|
+
try {
|
|
41
|
+
const ws = resolveWorkspace(workspaceId);
|
|
42
|
+
if (!ws) return null;
|
|
43
|
+
const bytes = Number.isSafeInteger(size) ? size
|
|
44
|
+
: kind === "file" && typeof rel === "string" ? sizeInsideSync(ws.root, rel.replace(/\\/g, "/"), { allowHardlinks: config.allowHardlinks }) : 0;
|
|
45
|
+
const minted = registry.mint({ workspaceId, root: ws.root, kind, rel, size: bytes, after });
|
|
46
|
+
if (minted && indexer) indexer.add({ workspaceId, kind, ref: minted.id, size: bytes, expiresAt: minted.expiresAt });
|
|
47
|
+
return minted;
|
|
48
|
+
} catch {
|
|
49
|
+
return null; // a failing workspace manager means "no id", never an exception with a path in it
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// F3-07c: the listing handler mints through mintRef, so every id it hands out is also announced with content.index
|
|
54
|
+
const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log, mint: mintRef });
|
|
33
55
|
link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
|
|
56
|
+
indexer = createContentIndexer({ session: link.session, registry, config, log });
|
|
34
57
|
|
|
35
58
|
const closeLink = link.close.bind(link);
|
|
36
|
-
link.close = () => { handler.cancelAll(); registry.clear(); closeLink(); };
|
|
59
|
+
link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
|
|
37
60
|
|
|
38
|
-
if (dlpConfigFromEnv(env).mode !== "enforce") {
|
|
39
|
-
(log.warn || log.log).call(log, "[la-content] content handlers are on but LOCAL_AGENT_DLP_MODE is not enforce: file content leaves this machine unmasked");
|
|
40
|
-
}
|
|
41
61
|
link.content = {
|
|
42
62
|
registry,
|
|
43
63
|
handler,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
64
|
+
indexer,
|
|
65
|
+
mintRef,
|
|
66
|
+
/** Announces again the live ids (of one workspace or all) that the gateway does not know yet. */
|
|
67
|
+
announce: (opts) => indexer.announce(opts),
|
|
48
68
|
};
|
|
49
69
|
return link.content;
|
|
50
70
|
}
|
|
@@ -3,15 +3,22 @@
|
|
|
3
3
|
* The gateway lets anything through (it only sees an opaque id); every check that matters is here.
|
|
4
4
|
*
|
|
5
5
|
* normalizeRel a relative path as the agent itself minted it: no NUL/control chars, not absolute, no `..`.
|
|
6
|
-
* isSensitivePath deny list (built-in + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
|
|
6
|
+
* isSensitivePath deny list (built-in globs + name rules + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
|
|
7
|
+
* F3-07c: names that merely MENTION a credential (credentials.service.ts, CredentialsController.java) are
|
|
8
|
+
* code and are served; files that ARE credentials (credentials, credentials.json, .credentials,
|
|
9
|
+
* application_default_credentials.json, ...) are not. See sensitiveByName(). The DLP stays the 2nd layer.
|
|
7
10
|
* resolveInside realpath of root and target; the real target must stay inside the real root (a symlink that
|
|
8
11
|
* leaves the workspace is `forbidden`), must not be sensitive under its REAL name either
|
|
9
12
|
* (a harmless-looking link to .env), and must be a regular file.
|
|
10
13
|
* readRegular opens with O_NOFOLLOW, checks it is the same inode that was checked, reads at most `maxBytes`.
|
|
11
14
|
*
|
|
12
15
|
* Failures are small codes ("not_found" | "forbidden" | "too_large"); no message ever carries a path.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
16
|
+
* Hard links (F3-07b): a file with more than one link (st.nlink > 1) is `forbidden` by default, because a hard link
|
|
17
|
+
* is indistinguishable from the file itself and may be a second name of a file OUTSIDE the root (the realpath check
|
|
18
|
+
* cannot see it). LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS=true lifts it (pnpm stores and some build trees hard-link
|
|
19
|
+
* every file). It is checked twice: on the path (resolveInside) and again on the open descriptor (readRegular).
|
|
20
|
+
* Limit that remains (documented in the note): a directory swapped for a symlink between the check and the open
|
|
21
|
+
* (TOCTOU on parents).
|
|
15
22
|
*/
|
|
16
23
|
import fs from "node:fs";
|
|
17
24
|
import path from "node:path";
|
|
@@ -21,12 +28,35 @@ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
|
21
28
|
/** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
|
|
22
29
|
export const DEFAULT_DENY_GLOBS = Object.freeze([
|
|
23
30
|
// secrets by name
|
|
24
|
-
".env", ".env.*", "*.env",
|
|
31
|
+
".env", ".env.*", "*.env", ".envs/",
|
|
25
32
|
"*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
|
|
26
33
|
"id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
|
|
27
34
|
".npmrc", ".pypirc", ".netrc", "_netrc", ".pgpass", ".my.cnf", ".git-credentials", ".htpasswd", ".vault-token",
|
|
28
|
-
"
|
|
35
|
+
"service-account*.json", "serviceaccount*.json", "secrets.json", "secrets.yml", "secrets.yaml",
|
|
36
|
+
// F3-07c: directories named for secrets (everything under them), by Edu's decision of 2026-11-03
|
|
37
|
+
"secrets/", ".secrets/", ".credentials/",
|
|
29
38
|
"*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
|
|
39
|
+
// F3-07b: editor backups / autosaves of the env file, kubeconfig files
|
|
40
|
+
".env~", ".env#", ".#.env", "*.kubeconfig",
|
|
41
|
+
// (F3-07c: .envrc, .env_backup, .env-prod, env.production, kubeconfig-prod, credentials* ... are name RULES, sensitiveByName() below)
|
|
42
|
+
// F3-07b: Windows 8.3 aliases ("short names") of the files above: ".env" is also reachable as ENV~1, ".npmrc" as NPMRC~1,
|
|
43
|
+
// "credentials.json" as CREDEN~1.JSO, "id_ed25519" as ID_ED2~1 ... The real name is checked as well (resolveInside), this
|
|
44
|
+
// covers the alias form on its own. `~?*` = "~" + at least one more character.
|
|
45
|
+
"env~?*", "npmrc~?*", "pypirc~?*", "netrc~?*", "pgpass~?*", "htpass~?*", "git-cr~?*", "vault-~?*", "kubeco~?*", "creden~?*",
|
|
46
|
+
"servic~?*", "secret~?*", "id_rsa~?*", "id_dsa~?*", "id_ecd~?*", "id_ed2~?*", "terraf~?*",
|
|
47
|
+
// (an alias keeps the first 3 characters of the extension: `*.pem`, `*.key`, `*.p12`... already catch their aliases; these are the ones whose
|
|
48
|
+
// extension is longer than 3: .tfstate -> .TFS, .tfvars -> .TFV, .kdbx -> .KDB, .secret(s) -> .SEC)
|
|
49
|
+
"*~?*.tfs", "*~?*.tfv", "*~?*.kdb", "*~?*.sec",
|
|
50
|
+
// F3-07c review: credential files that no rule above names (the DLP is only the second layer: it masks known token shapes, not plain values)
|
|
51
|
+
".dockercfg", ".yarnrc", ".yarnrc.yml", ".pnpmrc", ".s3cfg", ".boto", ".terraformrc", ".renviron", "auth.json",
|
|
52
|
+
"client_secret*.json", "firebase-adminsdk*.json", "gha-creds-*.json", "local.settings.json", "wp-config.php",
|
|
53
|
+
"application-prod.properties", "application-prod.yml", "application-prod.yaml",
|
|
54
|
+
"*_rsa", "*_dsa", "*_ecdsa", "*_ed25519", "ssh_host_*_key",
|
|
55
|
+
".m2/settings.xml", ".m2/settings-security.xml",
|
|
56
|
+
// family names that are also directories: everything under them is blocked (a bare `.env_backup/a.txt` was readable)
|
|
57
|
+
".envrc/", ".env_backup/", ".env-prod/", "env.production/", "kubeconfig-prod/",
|
|
58
|
+
// 8.3 aliases of .envrc / .envs
|
|
59
|
+
"envrc~?*", "envs~?*",
|
|
30
60
|
// directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
|
|
31
61
|
".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
|
|
32
62
|
]);
|
|
@@ -60,11 +90,74 @@ function foldForMatch(rel) {
|
|
|
60
90
|
return rel.split("/").map((seg) => seg.replace(/[. ]+$/, "") || seg).join("/").toLowerCase();
|
|
61
91
|
}
|
|
62
92
|
|
|
63
|
-
|
|
93
|
+
/**
|
|
94
|
+
* NTFS alternate data streams: ".env::$DATA" and ".env:stream" read the data of ".env". Matching also uses the name without
|
|
95
|
+
* the stream part of every segment (the original is matched too: on POSIX a colon is an ordinary character).
|
|
96
|
+
*/
|
|
97
|
+
function withoutStreams(folded) {
|
|
98
|
+
return folded.split("/").map((seg) => { const i = seg.indexOf(":"); return i > 0 ? seg.slice(0, i) : seg; }).join("/");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extensions of SOURCE / documentation files (F3-07c). A name that mentions a credential, a kubeconfig or an env file is
|
|
103
|
+
* served only when its last extension is one of these: credentials.service.ts, CredentialsController.java, credentials_test.py
|
|
104
|
+
* are code about credentials; credentials, credentials.json, .credentials, credentials.bak are credentials. Everything that is not
|
|
105
|
+
* known code fails closed (an unknown extension is NOT a reason to serve). Not in the list on purpose: data and config formats
|
|
106
|
+
* (json, yml, yaml, xml, toml, ini, cfg, conf, csv, txt, properties, db, sqlite, enc, env ...), shell/batch scripts and gradle
|
|
107
|
+
* (they export or hold values), key stores, backups (bak, old, orig, tmp, swp) and anything with no extension.
|
|
108
|
+
*/
|
|
109
|
+
export const CODE_EXTENSIONS = Object.freeze(new Set([
|
|
110
|
+
"ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "java", "kt", "kts", "py", "go", "rb", "cs", "rs", "php", "scala", "swift",
|
|
111
|
+
"vue", "svelte", "astro", "c", "cc", "cpp", "cxx", "h", "hh", "hpp", "m", "mm", "sql", "md", "markdown", "rst", "adoc", "html", "htm",
|
|
112
|
+
"css", "scss", "sass", "less", "dart", "ex", "exs", "erl", "hrl", "clj", "cljs", "lua", "pl", "pm", "r", "jl", "hs", "ml", "fs", "fsx",
|
|
113
|
+
"vb", "groovy", "proto", "graphql", "gql", "tf", "cshtml", "razor", "jsp", "ejs", "hbs", "pug", "zig", "nim", "sol",
|
|
114
|
+
]));
|
|
115
|
+
|
|
116
|
+
/** Last extension of a (folded) file name; "" for no extension and for a name that only starts with a dot. */
|
|
117
|
+
function extOf(name) {
|
|
118
|
+
const i = name.lastIndexOf(".");
|
|
119
|
+
return i > 0 ? name.slice(i + 1) : "";
|
|
120
|
+
}
|
|
121
|
+
const isCodeName = (name) => CODE_EXTENSIONS.has(extOf(name));
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Name rules (on top of the globs): the families whose names have to be told apart from source code. `segs` are the folded path
|
|
125
|
+
* segments; the last one is a directory when `dir` is true (a listing entry), a file otherwise.
|
|
126
|
+
* - .env family: `.env`, `.envrc`, then end or a separator (. _ - # ~): .env_backup, .env-prod, .env.local~, .envrc.local. Not .environment.
|
|
127
|
+
* - env.<x> / env_<x> / env-<x> and a bare `env` file: env.production, env_prod: unless code (env.ts, env.d.ts, env.config.mjs).
|
|
128
|
+
* - names with "credential" or "kubeconfig": unless code (credentials.service.ts) ... a DIRECTORY with such a name is fine
|
|
129
|
+
* (src/credentials/), but a file under one has to be code (credentials/prod.json is not), and `.credentials` is never fine.
|
|
130
|
+
*/
|
|
131
|
+
function sensitiveByName(segs, dir) {
|
|
132
|
+
const last = segs.length - 1;
|
|
133
|
+
for (let i = 0; i < segs.length; i++) {
|
|
134
|
+
const seg = segs[i];
|
|
135
|
+
const asDir = i < last || dir;
|
|
136
|
+
if (asDir) {
|
|
137
|
+
if (seg === ".credentials") return true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/^\.env(?:rc)?(?![a-z])/.test(seg)) return true;
|
|
141
|
+
if (seg === "env" || (/^env[._-]/.test(seg) && !isCodeName(seg))) return true;
|
|
142
|
+
if ((seg.includes("credential") || seg.includes("kubeconfig")) && !isCodeName(seg)) return true;
|
|
143
|
+
if (i > 0 && segs.slice(0, i).some((d) => d.includes("credential")) && !isCodeName(seg)) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} rel
|
|
150
|
+
* @param {string[]} [extraGlobs]
|
|
151
|
+
* @param {{dir?: boolean}} [o] dir: the last segment is a directory (a listing entry, a folder id): file-only name rules do not apply to it
|
|
152
|
+
*/
|
|
153
|
+
export function isSensitivePath(rel, extraGlobs = [], { dir = false } = {}) {
|
|
64
154
|
if (typeof rel !== "string") return true;
|
|
65
155
|
const folded = foldForMatch(rel.replace(/\\/g, "/"));
|
|
66
|
-
|
|
67
|
-
|
|
156
|
+
const stripped = withoutStreams(folded);
|
|
157
|
+
const forms = stripped === folded ? [folded] : [folded, stripped];
|
|
158
|
+
for (const g of DEFAULT_DENY_GLOBS) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
|
|
159
|
+
for (const f of forms) if (sensitiveByName(f.split("/"), dir)) return true;
|
|
160
|
+
for (const g of extraGlobs) if (typeof g === "string" && g) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
|
|
68
161
|
return false;
|
|
69
162
|
}
|
|
70
163
|
|
|
@@ -93,7 +186,7 @@ const inside = (root, abs) => abs === root || abs.startsWith(root.endsWith(path.
|
|
|
93
186
|
* @param {string} rel relative path already normalized (normalizeRel)
|
|
94
187
|
* @returns {Promise<{ok:true, real:string, relReal:string, stat:fs.Stats} | {ok:false, status:"not_found"|"forbidden"}>}
|
|
95
188
|
*/
|
|
96
|
-
export async function resolveInside(root, rel, extraGlobs = []) {
|
|
189
|
+
export async function resolveInside(root, rel, extraGlobs = [], { allowHardlinks = false } = {}) {
|
|
97
190
|
let rootReal;
|
|
98
191
|
try { rootReal = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
|
|
99
192
|
const abs = path.join(rootReal, ...rel.split("/"));
|
|
@@ -107,21 +200,38 @@ export async function resolveInside(root, rel, extraGlobs = []) {
|
|
|
107
200
|
let st;
|
|
108
201
|
try { st = await fs.promises.lstat(real); } catch { return { ok: false, status: "not_found" }; }
|
|
109
202
|
if (!st.isFile()) return { ok: false, status: "not_found" };
|
|
203
|
+
if (st.nlink > 1 && !allowHardlinks) return { ok: false, status: "forbidden" }; // a hard link may be a second name of a file outside the root
|
|
110
204
|
return { ok: true, real, relReal, stat: st };
|
|
111
205
|
}
|
|
112
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Size of a regular file inside the root, for content.index (informative). 0 for anything else: a link that leaves the
|
|
209
|
+
* root, a missing file, a hard link (when refused). Nothing is read and nothing but a number comes back.
|
|
210
|
+
*/
|
|
211
|
+
export function sizeInsideSync(root, rel, { allowHardlinks = false } = {}) {
|
|
212
|
+
try {
|
|
213
|
+
const rootReal = fs.realpathSync(root);
|
|
214
|
+
const real = fs.realpathSync(path.join(rootReal, ...rel.split("/")));
|
|
215
|
+
if (!inside(rootReal, real) || real === rootReal) return 0;
|
|
216
|
+
const st = fs.lstatSync(real);
|
|
217
|
+
if (!st.isFile() || (st.nlink > 1 && !allowHardlinks)) return 0;
|
|
218
|
+
return st.size;
|
|
219
|
+
} catch { return 0; }
|
|
220
|
+
}
|
|
221
|
+
|
|
113
222
|
/**
|
|
114
223
|
* Reads the whole file (<= maxBytes) through a descriptor opened without following links and verified to be the
|
|
115
224
|
* file that resolveInside() checked.
|
|
116
225
|
* @returns {Promise<{ok:true, buffer:Buffer} | {ok:false, status:"not_found"|"forbidden"|"too_large"}>}
|
|
117
226
|
*/
|
|
118
|
-
export async function readRegular(resolved, maxBytes, signal) {
|
|
227
|
+
export async function readRegular(resolved, maxBytes, signal, { allowHardlinks = false } = {}) {
|
|
119
228
|
if (resolved.stat.size > maxBytes) return { ok: false, status: "too_large" };
|
|
120
229
|
let fh;
|
|
121
230
|
try {
|
|
122
231
|
fh = await fs.promises.open(resolved.real, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
123
232
|
const st = await fh.stat();
|
|
124
233
|
if (!st.isFile()) return { ok: false, status: "not_found" };
|
|
234
|
+
if (st.nlink > 1 && !allowHardlinks) return { ok: false, status: "forbidden" }; // linked again after the path check
|
|
125
235
|
if (resolved.stat.ino !== 0 && (st.ino !== resolved.stat.ino || st.dev !== resolved.stat.dev)) return { ok: false, status: "forbidden" };
|
|
126
236
|
if (st.size > maxBytes) return { ok: false, status: "too_large" };
|
|
127
237
|
const buf = Buffer.allocUnsafe(Math.min(st.size, maxBytes) + 1);
|