dsh-plugin-compat-check 0.1.5 → 0.1.7
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/README.md +17 -28
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/index.js +1295 -550
- package/kernel/lib/api-semantics.mjs +223 -0
- package/kernel/lib/author-ci-budget.mjs +25 -0
- package/kernel/lib/author-ci-output.mjs +22 -0
- package/kernel/lib/author-ci-policy.mjs +6 -1
- package/kernel/lib/baseline.mjs +109 -38
- package/kernel/lib/catalog.mjs +11 -3
- package/kernel/lib/contracts.mjs +16 -1
- package/kernel/lib/execution-environment.mjs +52 -0
- package/kernel/lib/extract.mjs +57 -13
- package/kernel/lib/finding-guidance.mjs +6 -0
- package/kernel/lib/finding-review-store.mjs +11 -23
- package/kernel/lib/issue-report.mjs +2 -1
- package/kernel/lib/patch-mounts.mjs +232 -0
- package/kernel/lib/report-identity-worker.mjs +47 -26
- package/kernel/lib/report-identity.mjs +4 -3
- package/kernel/lib/report-service.mjs +118 -38
- package/kernel/lib/report.mjs +13 -17
- package/kernel/lib/source-analysis.mjs +258 -42
- package/kernel/lib/source-graph.mjs +353 -75
- package/kernel/lib/state-lock.mjs +206 -0
- package/kernel/lib/state-store.mjs +41 -30
- package/kernel/lib/static-input.mjs +111 -0
- package/kernel/lib/syntax.mjs +242 -20
- package/kernel/lib/upgrade-contract.mjs +1 -0
- package/kernel/lib/upgrade-input.mjs +9 -4
- package/kernel/lib/upgrade-preview.mjs +11 -15
- package/kernel/lib/upgrade-worker.mjs +10 -1
- package/kernel/lib/verdict.mjs +28 -5
- package/kernel/lib/version.mjs +2 -34
- package/kernel/lib/worker-analysis.mjs +74 -3
- package/kernel/rules/README.md +30 -208
- package/kernel/rules/dsh-0.2.1-rc.3-review.json +63 -0
- package/kernel/rules/dsh-api-catalog-0.2.1-rc.3.json +743 -0
- package/kernel/rules/severity.json +77 -0
- package/lib/client.js +4668 -3423
- package/lib/finding-guidance.js +6 -0
- package/lib/local-request.js +44 -0
- package/lib/report-contract.js +16 -1
- package/lib/upgrade-contract.js +1 -0
- package/package.json +10 -5
- package/scripts/prepublish-check.mjs +1 -22
- package/scripts/stage-contract.mjs +7 -0
- package/skill/SKILL.md +13 -12
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Private local state locks. Recovery never infers death from lock age or PID reuse. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
const MAX_BYTES = 8192;
|
|
7
|
+
const fail = (message, code = "STATE_LOCK_UNSAFE") =>
|
|
8
|
+
Object.assign(new Error(message), { code });
|
|
9
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
10
|
+
const guardPath = (lock) => lock + ".recovery-lock";
|
|
11
|
+
export function stateDirectory(dir, { create = false } = {}) {
|
|
12
|
+
if (create) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
const st = fs.lstatSync(dir);
|
|
14
|
+
if (
|
|
15
|
+
!st.isDirectory() ||
|
|
16
|
+
st.isSymbolicLink() ||
|
|
17
|
+
(process.getuid && st.uid !== process.getuid())
|
|
18
|
+
)
|
|
19
|
+
throw fail("Unsafe state directory");
|
|
20
|
+
if (st.mode & 0o077)
|
|
21
|
+
throw fail(
|
|
22
|
+
"State directory must be private (0700); existing permissions were not changed; recovery guidance: docs/guide.md#本地状态锁恢复",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
// A process-local start/instance marker, not an OS birth-time attestation.
|
|
26
|
+
// Any existing PID (including a reused PID) blocks recovery, so writers never
|
|
27
|
+
// need external commands or a platform process-list query to create metadata.
|
|
28
|
+
const selfBirth = `node:${new Date(Date.now() - process.uptime() * 1000).toISOString()}:${randomUUID()}`;
|
|
29
|
+
function record(lock) {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
kind: "dsh-plugin-compat-check/state-lock",
|
|
33
|
+
lock: path.resolve(lock),
|
|
34
|
+
pid: process.pid,
|
|
35
|
+
uid: process.getuid?.() ?? null,
|
|
36
|
+
host: os.hostname(),
|
|
37
|
+
birth: selfBirth,
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
nonce: randomUUID(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function readLock(lock) {
|
|
43
|
+
let fd;
|
|
44
|
+
try {
|
|
45
|
+
fd = fs.openSync(
|
|
46
|
+
lock,
|
|
47
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
|
|
48
|
+
);
|
|
49
|
+
const st = fs.fstatSync(fd);
|
|
50
|
+
if (
|
|
51
|
+
!st.isFile() ||
|
|
52
|
+
st.nlink !== 1 ||
|
|
53
|
+
st.size > MAX_BYTES ||
|
|
54
|
+
(process.getuid && st.uid !== process.getuid()) ||
|
|
55
|
+
st.mode & 0o022
|
|
56
|
+
)
|
|
57
|
+
throw fail("Lock is nonregular, oversized, linked, or unsafe");
|
|
58
|
+
const buf = Buffer.alloc(MAX_BYTES + 1);
|
|
59
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
60
|
+
if (n > MAX_BYTES) throw fail("Lock metadata exceeds limit");
|
|
61
|
+
const bytes = buf.subarray(0, n);
|
|
62
|
+
if (!same(st, fs.lstatSync(lock))) throw fail("Lock changed while reading");
|
|
63
|
+
const token = createHash("sha256")
|
|
64
|
+
.update(`${st.dev}:${st.ino}:`)
|
|
65
|
+
.update(bytes)
|
|
66
|
+
.digest("hex");
|
|
67
|
+
return { st, bytes, token };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === "STATE_LOCK_UNSAFE") throw error;
|
|
70
|
+
throw fail(`Lock cannot be read safely: ${error.code || error.message}`);
|
|
71
|
+
} finally {
|
|
72
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function unlinkSame(lock, original) {
|
|
76
|
+
const current = readLock(lock);
|
|
77
|
+
if (!same(current.st, original.st) || !current.bytes.equals(original.bytes))
|
|
78
|
+
throw fail("Lock changed; refusing to remove replacement");
|
|
79
|
+
fs.unlinkSync(lock);
|
|
80
|
+
}
|
|
81
|
+
function createLock(lock) {
|
|
82
|
+
const fd = fs.openSync(
|
|
83
|
+
lock,
|
|
84
|
+
fs.constants.O_CREAT |
|
|
85
|
+
fs.constants.O_EXCL |
|
|
86
|
+
fs.constants.O_WRONLY |
|
|
87
|
+
fs.constants.O_NOFOLLOW,
|
|
88
|
+
0o600,
|
|
89
|
+
);
|
|
90
|
+
const st = fs.fstatSync(fd);
|
|
91
|
+
let owned;
|
|
92
|
+
try {
|
|
93
|
+
const bytes = Buffer.from(JSON.stringify(record(lock)) + "\n");
|
|
94
|
+
fs.writeFileSync(fd, bytes);
|
|
95
|
+
fs.fsyncSync(fd);
|
|
96
|
+
owned = { st, bytes };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// An incomplete record is deliberately left for explicit operator diagnosis.
|
|
99
|
+
fs.closeSync(fd);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
let released = false;
|
|
103
|
+
return {
|
|
104
|
+
release() {
|
|
105
|
+
if (released) return;
|
|
106
|
+
released = true;
|
|
107
|
+
try {
|
|
108
|
+
unlinkSame(lock, owned);
|
|
109
|
+
} catch {
|
|
110
|
+
} finally {
|
|
111
|
+
fs.closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function noRecovery(lock) {
|
|
117
|
+
try {
|
|
118
|
+
fs.lstatSync(guardPath(lock));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error.code === "ENOENT") return;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
throw fail(
|
|
124
|
+
"State lock recovery is in progress or interrupted; recovery guard requires operator diagnosis",
|
|
125
|
+
"EEXIST",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
export function acquireStateLock(lock) {
|
|
129
|
+
stateDirectory(path.dirname(lock), { create: true });
|
|
130
|
+
noRecovery(lock);
|
|
131
|
+
const held = createLock(lock);
|
|
132
|
+
try {
|
|
133
|
+
noRecovery(lock);
|
|
134
|
+
return held;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
held.release();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function orphan(lock) {
|
|
141
|
+
const observed = readLock(lock);
|
|
142
|
+
let value;
|
|
143
|
+
try {
|
|
144
|
+
value = JSON.parse(observed.bytes.toString("utf8"));
|
|
145
|
+
} catch {
|
|
146
|
+
throw fail("Legacy empty or malformed lock; automatic recovery refused");
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
value?.schemaVersion !== 1 ||
|
|
150
|
+
value.kind !== "dsh-plugin-compat-check/state-lock" ||
|
|
151
|
+
value.lock !== lock ||
|
|
152
|
+
!Number.isSafeInteger(value.pid) ||
|
|
153
|
+
value.pid <= 1 ||
|
|
154
|
+
typeof value.host !== "string" ||
|
|
155
|
+
typeof value.birth !== "string" ||
|
|
156
|
+
!value.birth ||
|
|
157
|
+
typeof value.nonce !== "string" ||
|
|
158
|
+
value.nonce.length < 16 ||
|
|
159
|
+
typeof value.createdAt !== "string" ||
|
|
160
|
+
!Number.isFinite(Date.parse(value.createdAt))
|
|
161
|
+
)
|
|
162
|
+
throw fail("Legacy or incomplete holder identity; recovery refused");
|
|
163
|
+
if (
|
|
164
|
+
value.host !== os.hostname() ||
|
|
165
|
+
value.uid !== (process.getuid?.() ?? null)
|
|
166
|
+
)
|
|
167
|
+
throw fail("Holder host or user identity differs; recovery refused");
|
|
168
|
+
try {
|
|
169
|
+
process.kill(value.pid, 0);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error.code === "ESRCH") return { ...observed, holder: value };
|
|
172
|
+
throw fail(
|
|
173
|
+
`Holder identity is unknown (${error.code || "signal failed"}); recovery refused`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throw fail(
|
|
177
|
+
"Holder PID is active, reused or identity cannot be confirmed; recovery refused",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
/** Trusted local entry point only. Tool/path-bound state lock, no wildcard cleanup. */
|
|
181
|
+
export function recoverStateLock(lock, { confirm } = {}) {
|
|
182
|
+
lock = path.resolve(lock);
|
|
183
|
+
if (!path.basename(lock).endsWith(".lock") || path.basename(lock) === ".lock")
|
|
184
|
+
throw fail("Expected an exact state .lock path");
|
|
185
|
+
stateDirectory(path.dirname(lock));
|
|
186
|
+
const guard = createLock(guardPath(lock));
|
|
187
|
+
try {
|
|
188
|
+
const observed = orphan(lock);
|
|
189
|
+
if (confirm === undefined)
|
|
190
|
+
return {
|
|
191
|
+
status: "orphan",
|
|
192
|
+
lock,
|
|
193
|
+
token: observed.token,
|
|
194
|
+
holder: observed.holder,
|
|
195
|
+
};
|
|
196
|
+
if (typeof confirm !== "string" || confirm !== observed.token)
|
|
197
|
+
throw fail("Confirmation token is stale; inspect this exact lock again");
|
|
198
|
+
const checked = orphan(lock);
|
|
199
|
+
if (checked.token !== observed.token)
|
|
200
|
+
throw fail("Lock changed during recovery");
|
|
201
|
+
unlinkSame(lock, observed);
|
|
202
|
+
return { status: "recovered", lock };
|
|
203
|
+
} finally {
|
|
204
|
+
guard.release();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -4,6 +4,9 @@ import os from "node:os";
|
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { diffAgainstBaseline, badgeFor } from "./baseline.mjs";
|
|
6
6
|
import { TOOL_VERSION } from "./version.mjs";
|
|
7
|
+
import { acquireStateLock, stateDirectory } from "./state-lock.mjs";
|
|
8
|
+
import { readStaticFile } from "./static-input.mjs";
|
|
9
|
+
import { validateBaseline } from "./contracts.mjs";
|
|
7
10
|
|
|
8
11
|
export const digest = (value) =>
|
|
9
12
|
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
@@ -41,11 +44,31 @@ export function baselinePath(nm, mode = "catalogue", profile = null) {
|
|
|
41
44
|
}
|
|
42
45
|
export function readBaseline(file) {
|
|
43
46
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
stateDirectory(path.dirname(file));
|
|
48
|
+
const prev = JSON.parse(
|
|
49
|
+
readStaticFile(file, 8 * 1024 * 1024).toString("utf8"),
|
|
50
|
+
);
|
|
51
|
+
if (validateBaseline(prev).length)
|
|
52
|
+
throw new Error("Invalid baseline structure");
|
|
53
|
+
return {
|
|
54
|
+
prev,
|
|
55
|
+
note: null,
|
|
56
|
+
status: "valid",
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code === "ENOENT")
|
|
60
|
+
return {
|
|
61
|
+
prev: null,
|
|
62
|
+
note: "还没有基线(这是第一次体检)",
|
|
63
|
+
status: "missing",
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
prev: null,
|
|
67
|
+
status: "unavailable",
|
|
68
|
+
note:
|
|
69
|
+
"基线格式不支持、损坏或不可安全读取,已保留原件;请人工核实或恢复可信备份后重试,本次不比较或覆盖" +
|
|
70
|
+
(error.code === "STATE_LOCK_UNSAFE" ? ";" + error.message : ""),
|
|
71
|
+
};
|
|
49
72
|
}
|
|
50
73
|
}
|
|
51
74
|
/** Atomic replace never follows an existing output-file symlink. */
|
|
@@ -55,7 +78,7 @@ export function writeBaseline(file, snapshot) {
|
|
|
55
78
|
`.baseline-${randomUUID()}.tmp`,
|
|
56
79
|
);
|
|
57
80
|
try {
|
|
58
|
-
|
|
81
|
+
stateDirectory(path.dirname(file), { create: true });
|
|
59
82
|
fs.writeFileSync(temporary, JSON.stringify(snapshot, null, 2) + "\n", {
|
|
60
83
|
flag: "wx",
|
|
61
84
|
mode: 0o600,
|
|
@@ -73,27 +96,20 @@ export function writeBaseline(file, snapshot) {
|
|
|
73
96
|
|
|
74
97
|
/** Serialize read/compare/replace across processes. Busy locks never overwrite newer state. */
|
|
75
98
|
export function baselineTransaction(file, snapshot, { advance = true } = {}) {
|
|
76
|
-
let
|
|
99
|
+
let heldLock = null;
|
|
77
100
|
const lock = file + ".lock";
|
|
78
101
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
!stat.isDirectory() ||
|
|
84
|
-
stat.isSymbolicLink() ||
|
|
85
|
-
(typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
86
|
-
)
|
|
87
|
-
throw new Error("Unsafe baseline directory");
|
|
88
|
-
if (stat.mode & 0o077) fs.chmodSync(dir, 0o700);
|
|
89
|
-
fd = fs.openSync(
|
|
90
|
-
lock,
|
|
91
|
-
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
|
|
92
|
-
0o600,
|
|
93
|
-
);
|
|
94
|
-
const { prev, note } = readBaseline(file),
|
|
102
|
+
heldLock = acquireStateLock(lock);
|
|
103
|
+
if (validateBaseline(snapshot).length)
|
|
104
|
+
throw new Error("新基线结构无效,未写入;保留原状态");
|
|
105
|
+
const { prev, note, status } = readBaseline(file),
|
|
95
106
|
diff = diffAgainstBaseline(prev, snapshot);
|
|
107
|
+
if (status === "unavailable") diff.reason = note;
|
|
96
108
|
const out = { file, note, diff, badge: badgeFor(diff), wrote: false };
|
|
109
|
+
if (status === "unavailable") {
|
|
110
|
+
out.writeError = note;
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
97
113
|
if (advance) {
|
|
98
114
|
const error = writeBaseline(file, snapshot);
|
|
99
115
|
if (error) out.writeError = error;
|
|
@@ -112,15 +128,10 @@ export function baselineTransaction(file, snapshot, { advance = true } = {}) {
|
|
|
112
128
|
wrote: false,
|
|
113
129
|
writeError:
|
|
114
130
|
error.code === "EEXIST"
|
|
115
|
-
?
|
|
131
|
+
? `另一个进程正在更新基线或锁已遗留;本次保留上次状态。可用对应源码仓库的 bin/recover-lock.mjs 检查 ${lock}`
|
|
116
132
|
: error.message,
|
|
117
133
|
};
|
|
118
134
|
} finally {
|
|
119
|
-
|
|
120
|
-
fs.closeSync(fd);
|
|
121
|
-
try {
|
|
122
|
-
fs.unlinkSync(lock);
|
|
123
|
-
} catch {}
|
|
124
|
-
}
|
|
135
|
+
heldLock?.release();
|
|
125
136
|
}
|
|
126
137
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Filesystem inputs are data. Never open a FIFO in blocking mode or read to EOF. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const inputError = (code, message) =>
|
|
5
|
+
Object.assign(new Error(message), { code });
|
|
6
|
+
const stamp = (s) => [s.dev, s.ino, s.size, s.mtimeMs, s.ctimeMs];
|
|
7
|
+
const same = (a, b) => JSON.stringify(stamp(a)) === JSON.stringify(stamp(b));
|
|
8
|
+
|
|
9
|
+
export function lowerInputLimits(defaults, overrides = {}) {
|
|
10
|
+
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides))
|
|
11
|
+
throw inputError("STATIC_INPUT_BUDGET", "Invalid static input budget");
|
|
12
|
+
for (const [key, value] of Object.entries(overrides))
|
|
13
|
+
if (
|
|
14
|
+
!Object.hasOwn(defaults, key) ||
|
|
15
|
+
!Number.isSafeInteger(value) ||
|
|
16
|
+
value <= 0 ||
|
|
17
|
+
value > defaults[key]
|
|
18
|
+
)
|
|
19
|
+
throw inputError(
|
|
20
|
+
"STATIC_INPUT_BUDGET",
|
|
21
|
+
"Invalid static input budget: " + key,
|
|
22
|
+
);
|
|
23
|
+
return { ...defaults, ...overrides };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readStaticFile(file, maxBytes, check = () => {}) {
|
|
27
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
28
|
+
throw inputError("STATIC_INPUT_BUDGET", "Invalid static input byte budget");
|
|
29
|
+
let fd,
|
|
30
|
+
observed = false;
|
|
31
|
+
try {
|
|
32
|
+
check();
|
|
33
|
+
const before = fs.lstatSync(file);
|
|
34
|
+
observed = true;
|
|
35
|
+
if (!before.isFile())
|
|
36
|
+
throw inputError("STATIC_INPUT_TYPE", "Input is not a regular file");
|
|
37
|
+
if (before.size > maxBytes)
|
|
38
|
+
throw inputError("STATIC_INPUT_BUDGET", "Input byte budget exceeded");
|
|
39
|
+
fd = fs.openSync(
|
|
40
|
+
file,
|
|
41
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
|
|
42
|
+
);
|
|
43
|
+
const opened = fs.fstatSync(fd);
|
|
44
|
+
if (!opened.isFile() || !same(before, opened))
|
|
45
|
+
throw inputError("STATIC_INPUT_CHANGED", "Input identity changed");
|
|
46
|
+
const buffer = Buffer.alloc(before.size + 1);
|
|
47
|
+
let length = 0;
|
|
48
|
+
while (length < buffer.length) {
|
|
49
|
+
check();
|
|
50
|
+
const n = fs.readSync(
|
|
51
|
+
fd,
|
|
52
|
+
buffer,
|
|
53
|
+
length,
|
|
54
|
+
Math.min(buffer.length - length, 65536),
|
|
55
|
+
null,
|
|
56
|
+
);
|
|
57
|
+
if (!n) break;
|
|
58
|
+
length += n;
|
|
59
|
+
}
|
|
60
|
+
if (length > maxBytes)
|
|
61
|
+
throw inputError("STATIC_INPUT_BUDGET", "Input byte budget exceeded");
|
|
62
|
+
if (
|
|
63
|
+
length !== before.size ||
|
|
64
|
+
!same(before, fs.fstatSync(fd)) ||
|
|
65
|
+
!same(before, fs.lstatSync(file))
|
|
66
|
+
)
|
|
67
|
+
throw inputError(
|
|
68
|
+
"STATIC_INPUT_CHANGED",
|
|
69
|
+
"Input changed during bounded read",
|
|
70
|
+
);
|
|
71
|
+
return buffer.subarray(0, length);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (observed && ["ENOENT", "ENOTDIR"].includes(error.code))
|
|
74
|
+
throw inputError(
|
|
75
|
+
"STATIC_INPUT_CHANGED",
|
|
76
|
+
"Input path changed during bounded read",
|
|
77
|
+
);
|
|
78
|
+
throw error;
|
|
79
|
+
} finally {
|
|
80
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Collect only a bounded directory listing; close the handle even on timeout. */
|
|
85
|
+
export function readStaticDirectory(dir, maxEntries, check = () => {}) {
|
|
86
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 0)
|
|
87
|
+
throw inputError(
|
|
88
|
+
"STATIC_INPUT_BUDGET",
|
|
89
|
+
"Invalid static input entry budget",
|
|
90
|
+
);
|
|
91
|
+
const handle = fs.opendirSync(dir),
|
|
92
|
+
entries = [];
|
|
93
|
+
try {
|
|
94
|
+
for (;;) {
|
|
95
|
+
check();
|
|
96
|
+
const entry = handle.readSync();
|
|
97
|
+
if (!entry) break;
|
|
98
|
+
if (entries.length >= maxEntries)
|
|
99
|
+
throw inputError(
|
|
100
|
+
"STATIC_INPUT_BUDGET",
|
|
101
|
+
"Directory entry budget exceeded",
|
|
102
|
+
);
|
|
103
|
+
entries.push(entry.name);
|
|
104
|
+
}
|
|
105
|
+
entries.sort();
|
|
106
|
+
check();
|
|
107
|
+
return entries;
|
|
108
|
+
} finally {
|
|
109
|
+
handle.closeSync();
|
|
110
|
+
}
|
|
111
|
+
}
|
package/kernel/lib/syntax.mjs
CHANGED
|
@@ -46,28 +46,250 @@ export function childNodes(node) {
|
|
|
46
46
|
return out;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/** Prove only immutable lexical primitive expressions; never evaluate target code. */
|
|
50
|
+
export function localImports(text, file, check = () => {}) {
|
|
50
51
|
const { ast, error } = parseSource(text, file);
|
|
51
|
-
if (!ast) return { imports: [], error };
|
|
52
|
+
if (!ast) return { imports: [], edges: [], diagnostics: [], error };
|
|
52
53
|
const imports = new Set(),
|
|
53
|
-
|
|
54
|
+
edges = [],
|
|
55
|
+
diagnostics = [],
|
|
56
|
+
scopes = new WeakMap(),
|
|
57
|
+
nodes = [];
|
|
58
|
+
const names = (n, out = []) => {
|
|
59
|
+
if (!n) return out;
|
|
60
|
+
if (n.type === "Identifier") out.push(n.name);
|
|
61
|
+
else if (n.type === "RestElement") names(n.argument, out);
|
|
62
|
+
else if (n.type === "TSParameterProperty") names(n.parameter, out);
|
|
63
|
+
else if (n.type === "AssignmentPattern") names(n.left, out);
|
|
64
|
+
else if (n.type === "ObjectPattern")
|
|
65
|
+
for (const p of n.properties) names(p.value ?? p.argument, out);
|
|
66
|
+
else if (n.type === "ArrayPattern")
|
|
67
|
+
for (const p of n.elements) names(p, out);
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
const makeScope = (parent, fn = false) => ({
|
|
71
|
+
parent,
|
|
72
|
+
fn,
|
|
73
|
+
bindings: new Map(),
|
|
74
|
+
});
|
|
75
|
+
const root = makeScope(null, true),
|
|
76
|
+
queue = [{ node: ast, scope: root }];
|
|
77
|
+
const find = (scope, name) => {
|
|
78
|
+
for (let s = scope; s; s = s.parent)
|
|
79
|
+
if (s.bindings.has(name)) return s.bindings.get(name);
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
const functionScope = (scope) => {
|
|
83
|
+
while (!scope.fn) scope = scope.parent;
|
|
84
|
+
return scope;
|
|
85
|
+
};
|
|
86
|
+
const declare = (scope, name, binding = {}) =>
|
|
87
|
+
scope.bindings.set(name, binding);
|
|
88
|
+
const note = (kind, node, message) => {
|
|
89
|
+
if (diagnostics.length < 100)
|
|
90
|
+
diagnostics.push({ kind, start: node?.start ?? null, message });
|
|
91
|
+
};
|
|
54
92
|
while (queue.length) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
if (nodes.length % 128 === 0) check();
|
|
94
|
+
if (nodes.length >= 200000) {
|
|
95
|
+
note("module-path-budget", null, "Module path AST budget exceeded");
|
|
96
|
+
return { imports: [], edges: [], diagnostics, error: null };
|
|
97
|
+
}
|
|
98
|
+
const { node, scope: parent } = queue.pop();
|
|
99
|
+
let scope = parent;
|
|
100
|
+
const fn =
|
|
101
|
+
/^(?:FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ObjectMethod|ClassMethod|ClassPrivateMethod)$/.test(
|
|
102
|
+
node.type,
|
|
103
|
+
);
|
|
104
|
+
if (node.type === "FunctionDeclaration" && node.id)
|
|
105
|
+
declare(parent, node.id.name);
|
|
106
|
+
if (
|
|
107
|
+
[
|
|
108
|
+
"ClassDeclaration",
|
|
109
|
+
"TSEnumDeclaration",
|
|
110
|
+
"TSDeclareFunction",
|
|
111
|
+
"TSImportEqualsDeclaration",
|
|
112
|
+
"TSModuleDeclaration",
|
|
113
|
+
].includes(node.type) &&
|
|
114
|
+
node.id?.type === "Identifier"
|
|
115
|
+
)
|
|
116
|
+
declare(parent, node.id.name);
|
|
117
|
+
if (fn) {
|
|
118
|
+
scope = makeScope(parent, true);
|
|
119
|
+
if (node.id) declare(scope, node.id.name);
|
|
120
|
+
for (const p of node.params)
|
|
121
|
+
for (const name of names(p)) declare(scope, name);
|
|
122
|
+
} else if (
|
|
123
|
+
["ClassDeclaration", "ClassExpression", "TSModuleDeclaration"].includes(
|
|
124
|
+
node.type,
|
|
125
|
+
)
|
|
126
|
+
) {
|
|
127
|
+
scope = makeScope(parent, node.type === "TSModuleDeclaration");
|
|
128
|
+
if (node.id?.type === "Identifier") declare(scope, node.id.name);
|
|
129
|
+
} else if (node.type === "StaticBlock") scope = makeScope(parent, true);
|
|
130
|
+
else if (
|
|
131
|
+
[
|
|
132
|
+
"BlockStatement",
|
|
133
|
+
"TSModuleBlock",
|
|
134
|
+
"ForStatement",
|
|
135
|
+
"ForInStatement",
|
|
136
|
+
"ForOfStatement",
|
|
137
|
+
"CatchClause",
|
|
138
|
+
"SwitchStatement",
|
|
139
|
+
"ClassBody",
|
|
140
|
+
"WithStatement",
|
|
141
|
+
].includes(node.type)
|
|
142
|
+
)
|
|
143
|
+
scope = makeScope(parent);
|
|
144
|
+
if (node.type === "WithStatement") scope.opaque = true;
|
|
145
|
+
if (node.type === "CatchClause")
|
|
146
|
+
for (const name of names(node.param)) declare(scope, name);
|
|
147
|
+
if (node.type === "VariableDeclaration")
|
|
148
|
+
for (const d of node.declarations)
|
|
149
|
+
for (const name of names(d.id))
|
|
150
|
+
declare(node.kind === "var" ? functionScope(scope) : scope, name, {
|
|
151
|
+
constant: node.kind === "const" && d.id.type === "Identifier",
|
|
152
|
+
init: d.init,
|
|
153
|
+
start: d.start,
|
|
154
|
+
});
|
|
155
|
+
if (node.type === "ImportDeclaration")
|
|
156
|
+
for (const spec of node.specifiers) declare(scope, spec.local.name);
|
|
157
|
+
scopes.set(node, scope);
|
|
158
|
+
nodes.push(node);
|
|
159
|
+
for (const child of childNodes(node)) queue.push({ node: child, scope });
|
|
71
160
|
}
|
|
72
|
-
|
|
161
|
+
// Hoisted declarations and all writes are known before proving any path.
|
|
162
|
+
for (const [index, node] of nodes.entries()) {
|
|
163
|
+
if (index % 128 === 0) check();
|
|
164
|
+
const scope = scopes.get(node);
|
|
165
|
+
if (
|
|
166
|
+
node.type === "AssignmentExpression" ||
|
|
167
|
+
node.type === "UpdateExpression"
|
|
168
|
+
)
|
|
169
|
+
for (const name of names(node.left ?? node.argument)) {
|
|
170
|
+
const binding = find(scope, name);
|
|
171
|
+
if (binding) binding.changed = true;
|
|
172
|
+
else if (name === "require") root.requireChanged = true;
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
node.type === "CallExpression" &&
|
|
176
|
+
node.callee.type === "Identifier" &&
|
|
177
|
+
node.callee.name === "eval" &&
|
|
178
|
+
!find(scope, "eval")
|
|
179
|
+
) {
|
|
180
|
+
functionScope(scope).opaque = true;
|
|
181
|
+
root.requireChanged = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const opaque = (scope, key = "opaque") => {
|
|
185
|
+
for (let s = scope; s; s = s.parent) if (s[key]) return true;
|
|
186
|
+
return false;
|
|
187
|
+
};
|
|
188
|
+
for (const [index, node] of nodes.entries()) {
|
|
189
|
+
if (index % 128 === 0) check();
|
|
190
|
+
const dynamic = node.type === "ImportExpression";
|
|
191
|
+
const required =
|
|
192
|
+
["CallExpression", "OptionalCallExpression"].includes(node.type) &&
|
|
193
|
+
node.callee?.type === "Identifier" &&
|
|
194
|
+
node.callee.name === "require";
|
|
195
|
+
const declared =
|
|
196
|
+
[
|
|
197
|
+
"ImportDeclaration",
|
|
198
|
+
"ExportNamedDeclaration",
|
|
199
|
+
"ExportAllDeclaration",
|
|
200
|
+
].includes(node.type) && !!node.source;
|
|
201
|
+
if (!dynamic && !required && !declared) continue;
|
|
202
|
+
const scope = scopes.get(node);
|
|
203
|
+
if (required && find(scope, "require")) continue;
|
|
204
|
+
const source = required ? node.arguments[0] : node.source;
|
|
205
|
+
let steps = 0,
|
|
206
|
+
budget = false;
|
|
207
|
+
const evaluate = (n, seen = new Set(), depth = 0) => {
|
|
208
|
+
if (!n) return null;
|
|
209
|
+
if (++steps > 128 || depth > 32) {
|
|
210
|
+
budget = true;
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
let value;
|
|
214
|
+
if (
|
|
215
|
+
[
|
|
216
|
+
"TSAsExpression",
|
|
217
|
+
"TSTypeAssertion",
|
|
218
|
+
"TSNonNullExpression",
|
|
219
|
+
"ParenthesizedExpression",
|
|
220
|
+
].includes(n.type)
|
|
221
|
+
)
|
|
222
|
+
return evaluate(n.expression, seen, depth + 1);
|
|
223
|
+
if (
|
|
224
|
+
["StringLiteral", "NumericLiteral", "BooleanLiteral"].includes(n.type)
|
|
225
|
+
)
|
|
226
|
+
value = n.value;
|
|
227
|
+
else if (n.type === "Identifier") {
|
|
228
|
+
const binding = find(scopes.get(n), n.name);
|
|
229
|
+
if (
|
|
230
|
+
opaque(scopes.get(n)) ||
|
|
231
|
+
!binding?.constant ||
|
|
232
|
+
binding.changed ||
|
|
233
|
+
!binding.init ||
|
|
234
|
+
binding.start >= n.start ||
|
|
235
|
+
seen.has(binding)
|
|
236
|
+
)
|
|
237
|
+
return null;
|
|
238
|
+
const next = new Set(seen);
|
|
239
|
+
next.add(binding);
|
|
240
|
+
return evaluate(binding.init, next, depth + 1);
|
|
241
|
+
} else if (n.type === "BinaryExpression" && n.operator === "+") {
|
|
242
|
+
const a = evaluate(n.left, seen, depth + 1),
|
|
243
|
+
b = evaluate(n.right, seen, depth + 1);
|
|
244
|
+
if (a === null || b === null) return null;
|
|
245
|
+
value = a + b;
|
|
246
|
+
} else if (n.type === "TemplateLiteral") {
|
|
247
|
+
value = "";
|
|
248
|
+
for (let i = 0; i < n.quasis.length; i++) {
|
|
249
|
+
if (n.quasis[i].value.cooked === null) return null;
|
|
250
|
+
value += n.quasis[i].value.cooked;
|
|
251
|
+
if (i < n.expressions.length) {
|
|
252
|
+
const part = evaluate(n.expressions[i], seen, depth + 1);
|
|
253
|
+
if (part === null) return null;
|
|
254
|
+
value += String(part);
|
|
255
|
+
}
|
|
256
|
+
if (value.length > 8192) {
|
|
257
|
+
budget = true;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else return null;
|
|
262
|
+
if (typeof value === "string" && value.length > 8192) {
|
|
263
|
+
budget = true;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
};
|
|
268
|
+
const spec =
|
|
269
|
+
required && (opaque(scope) || opaque(scope, "requireChanged"))
|
|
270
|
+
? null
|
|
271
|
+
: evaluate(source);
|
|
272
|
+
if (typeof spec !== "string" || !spec) {
|
|
273
|
+
note(
|
|
274
|
+
budget ? "module-path-budget" : "module-path-uncertain",
|
|
275
|
+
node,
|
|
276
|
+
budget
|
|
277
|
+
? "Constant module path exceeds evaluation budget"
|
|
278
|
+
: "Module path cannot be proved as an immutable lexical string",
|
|
279
|
+
);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (!spec.startsWith(".")) continue;
|
|
283
|
+
if (edges.length >= 4096) {
|
|
284
|
+
note("module-path-budget", node, "Module reference budget exceeded");
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
imports.add(spec);
|
|
288
|
+
edges.push({
|
|
289
|
+
source: spec,
|
|
290
|
+
kind: required ? "require" : "import",
|
|
291
|
+
start: node.start,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return { imports: [...imports].sort(), edges, diagnostics, error: null };
|
|
73
295
|
}
|