@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.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/CHANGELOG.md +39 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +4 -4
- package/dist/{8551.index.js → 7033.index.js} +415 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +11 -6
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +48 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +15 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +4 -3
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +10 -6
- package/src/language/haskell-supply.js +42 -8
- package/src/language/haskell-web.js +128 -23
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +71 -12
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-module-resolver.js +346 -51
- package/src/language/resolved-pass.js +57 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- package/dist/9091.index.js +0 -3135
package/src/language/nix-sca.js
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
import { AdvisoryDb, matchComponent as matchHackage, reachability as hackageReachability, licensePolicy, hackagePurl, normalizeAdvisory } from './haskell-sca.js';
|
|
22
22
|
import { parseVersion as parsePvp } from './haskell-manifests.js';
|
|
23
|
+
import { advisorySeverity, NO_RATING_BASIS } from './cvss.js';
|
|
23
24
|
import { parseNix } from './nix-parser.js';
|
|
25
|
+
import { cpeKey } from './nix-advisory-feed.js';
|
|
24
26
|
|
|
25
27
|
export const NIX_SCA_VERSION = 'nix-sca/1';
|
|
26
28
|
const HEX40 = /^[0-9a-f]{40}$/i;
|
|
@@ -59,16 +61,18 @@ export function compareUpstream(a, b) {
|
|
|
59
61
|
// ── advisories ───────────────────────────────────────────────────────────────
|
|
60
62
|
const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
|
|
61
63
|
function eventsToRanges(events) {
|
|
62
|
-
|
|
64
|
+
// `introduced_excluding` is not an OSV event: it carries an exclusive start bound (the NVD live feed has them), so 8.0 is not in "after 8.0".
|
|
65
|
+
const out = []; let lo = null; let loExc = false;
|
|
63
66
|
for (const e of events || []) {
|
|
64
|
-
if ('introduced' in e) lo = e.introduced === '0' ? null : e.introduced;
|
|
65
|
-
else if ('
|
|
66
|
-
else if ('
|
|
67
|
+
if ('introduced' in e) { lo = e.introduced === '0' ? null : e.introduced; loExc = false; }
|
|
68
|
+
else if ('introduced_excluding' in e) { lo = e.introduced_excluding; loExc = true; }
|
|
69
|
+
else if ('fixed' in e) { out.push({ lo, loExc, hi: e.fixed, hiInc: false }); lo = null; loExc = false; }
|
|
70
|
+
else if ('last_affected' in e) { out.push({ lo, loExc, hi: e.last_affected, hiInc: true }); lo = null; loExc = false; }
|
|
67
71
|
}
|
|
68
|
-
if (events && events.some((e) => 'introduced' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, hi: null, hiInc: false });
|
|
72
|
+
if (events && events.some((e) => 'introduced' in e || 'introduced_excluding' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, loExc, hi: null, hiInc: false });
|
|
69
73
|
return out;
|
|
70
74
|
}
|
|
71
|
-
const inRange = (v, r) => (r.lo === null || compareUpstream(v, r.lo) >= 0) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
|
|
75
|
+
const inRange = (v, r) => (r.lo === null || (r.loExc ? compareUpstream(v, r.lo) > 0 : compareUpstream(v, r.lo) >= 0)) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
|
|
72
76
|
|
|
73
77
|
/** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
|
|
74
78
|
export function normalizeGenericAdvisory(rec) {
|
|
@@ -86,12 +90,15 @@ export function normalizeGenericAdvisory(rec) {
|
|
|
86
90
|
fixPatches: ((a.database_specific && a.database_specific.fix_patches) || (rec.database_specific && rec.database_specific.fix_patches) || []).map((p) => ({ name: p.name || null, sha256: p.sha256 ? sha(p.sha256) : null })),
|
|
87
91
|
});
|
|
88
92
|
}
|
|
89
|
-
return { id: rec.id, ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
|
|
93
|
+
return { id: rec.id, severityInfo: advisorySeverity(rec), ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
export class NixAdvisoryData {
|
|
93
|
-
constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30 } = {}) {
|
|
94
|
-
this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays;
|
|
97
|
+
constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, covered = null } = {}) {
|
|
98
|
+
this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays; this.now = now;
|
|
99
|
+
// `covered` is {'vendor:product': ISO time that CPE identity was last fully read from the live feed}. null means the snapshot makes no
|
|
100
|
+
// per-identity claim (a hand-built or operator-pinned snapshot) and is matched exactly as it always was.
|
|
101
|
+
this.covered = covered && typeof covered === 'object' ? covered : null;
|
|
95
102
|
this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
|
|
96
103
|
this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
|
|
97
104
|
const gen = generatedAt ? Date.parse(generatedAt) : NaN;
|
|
@@ -99,6 +106,13 @@ export class NixAdvisoryData {
|
|
|
99
106
|
this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
|
|
100
107
|
this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
|
|
101
108
|
}
|
|
109
|
+
/** 'covered' | 'uncovered' | 'stale' for a CPE identity ('vendor:product'); always 'covered' when the snapshot makes no per-identity claim. */
|
|
110
|
+
cpeCoverage(key) {
|
|
111
|
+
if (!this.covered) return 'covered';
|
|
112
|
+
const t = Date.parse(this.covered[key]);
|
|
113
|
+
if (!Number.isFinite(t)) return 'uncovered';
|
|
114
|
+
return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
|
|
115
|
+
}
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
// ── upstream identity ────────────────────────────────────────────────────────
|
|
@@ -125,7 +139,7 @@ export function upstreamIdentity(node, drvEnv, meta, overlay) {
|
|
|
125
139
|
const ids = meta && meta.identifiers ? meta.identifiers : null;
|
|
126
140
|
if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
|
|
127
141
|
const cpes = ids ? [].concat(ids.cpe || [], ids.possibleCPEs || [], ids.v1 && ids.v1.cpeParts ? [ids.v1.cpeParts] : []).filter(Boolean) : [];
|
|
128
|
-
const cpeStrings = cpes.map((
|
|
142
|
+
const cpeStrings = cpes.map(cpeKey).filter(Boolean); // 'vendor:product' from a full CPE string or a {vendor, product} part; malformed values are dropped
|
|
129
143
|
const uniqueCpe = [...new Set(cpeStrings)];
|
|
130
144
|
if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
|
|
131
145
|
else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
|
|
@@ -139,6 +153,32 @@ export function upstreamIdentity(node, drvEnv, meta, overlay) {
|
|
|
139
153
|
return { ...base, candidates: uniq, ambiguous: distinctTargets.size > 1 || (strong.length === 0 && uniq.length > 1) || uniq.some((c) => c.authority === 'candidate'), authority: strong.length && distinctTargets.size === 1 && !uniq.some((c) => c.authority === 'candidate') ? strong[0].authority : (uniq[0] ? 'name-only' : 'none'), haskell: !!isHs };
|
|
140
154
|
}
|
|
141
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The upstream identities of every dependency in a closure, in the form the matcher will use, so a pre-scan step can ask a live feed
|
|
158
|
+
* about exactly what the matcher will look up. `cpes` holds 'vendor:product' keys (explicit and candidate); a component with none is
|
|
159
|
+
* listed in `withoutCpe` so the caller can say what a CPE-keyed feed cannot cover.
|
|
160
|
+
*/
|
|
161
|
+
export function closureIdentities({ closure, drvEnv = {}, meta = {}, overlays = {} } = {}) {
|
|
162
|
+
const nodes = (closure && closure.nodes) || [];
|
|
163
|
+
const roots = new Set((closure && closure.roots) || []);
|
|
164
|
+
const subjects = new Map();
|
|
165
|
+
for (const n of nodes) {
|
|
166
|
+
if (n.kind !== 'output' || roots.has(n.id)) continue;
|
|
167
|
+
const key = n.deriver || n.id;
|
|
168
|
+
if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, node: n });
|
|
169
|
+
const s = subjects.get(key);
|
|
170
|
+
if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
|
|
171
|
+
}
|
|
172
|
+
const cpes = new Set(); const withoutCpe = [];
|
|
173
|
+
for (const s of subjects.values()) {
|
|
174
|
+
const node = s.node;
|
|
175
|
+
const ident = upstreamIdentity(node, (s.drv && drvEnv[s.drv]) || null, meta[node.pname] || meta[node.storeName] || null, overlays[node.pname] || null);
|
|
176
|
+
const mine = ident.candidates.filter((c) => c.cpe).map((c) => c.cpe);
|
|
177
|
+
if (mine.length) mine.forEach((k) => cpes.add(k)); else if (node.pname) withoutCpe.push(node.pname);
|
|
178
|
+
}
|
|
179
|
+
return { cpes: [...cpes].sort(), withoutCpe: [...new Set(withoutCpe)].sort() };
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
// ── overlays and patches ─────────────────────────────────────────────────────
|
|
143
183
|
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
144
184
|
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
@@ -297,11 +337,12 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
297
337
|
const st = decide(r, pe, ident);
|
|
298
338
|
matched = true;
|
|
299
339
|
if (haskellUsage) { const rr = hackageReachability(node.pname, haskellUsage.imports, haskellUsage.callees, symbols[adv.id] || null); tiers.reachability = { import: rr.import, function: rr.function, basis: rr.reason }; }
|
|
300
|
-
findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
|
|
340
|
+
findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases, severityInfo: adv.severityInfo }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
|
|
301
341
|
}
|
|
302
342
|
}
|
|
303
343
|
// generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
|
|
304
344
|
for (const adv of data.records) {
|
|
345
|
+
if (adv.withdrawn) continue;
|
|
305
346
|
for (const aff of adv.affected) {
|
|
306
347
|
const target = ident.candidates.find((c) => (c.purl && aff.purl && c.purl === aff.purl) || (c.cpe && aff.cpe && c.cpe === aff.cpe) || (c.name && aff.name && !aff.purl && !aff.cpe && c.name === aff.name && c.authority === 'name-only') || (c.ecosystem === 'Hackage' && aff.ecosystem === 'Hackage' && c.name === aff.name));
|
|
307
348
|
if (!target) continue;
|
|
@@ -320,7 +361,21 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
320
361
|
findings.push(finding({ ...base, tiers: { ...tiers } }, adv, st.status, st.reason, { source: data.source, ecosystem: aff.ecosystem, fixedIn: aff.fixedIn, patchEvidence: pe, identityBasis: target.basis, identityAuthority: target.authority }, { kev, epss }));
|
|
321
362
|
}
|
|
322
363
|
}
|
|
364
|
+
// A feed that records coverage per CPE identity can only vouch for the identities it actually read. Without a covered, explicit,
|
|
365
|
+
// unambiguous CPE identity, "no match" says nothing, so it is unknown (never not-affected).
|
|
366
|
+
let covGap = null;
|
|
367
|
+
if (!hackageGap && data.covered) {
|
|
368
|
+
const cpeCands = ident.candidates.filter((c) => c.cpe);
|
|
369
|
+
const firm = !ident.ambiguous ? cpeCands.find((c) => c.authority === 'explicit') : null;
|
|
370
|
+
if (firm) {
|
|
371
|
+
const cv = data.cpeCoverage(firm.cpe);
|
|
372
|
+
if (cv === 'covered') mapped = true;
|
|
373
|
+
else covGap = cv === 'stale' ? `the live advisory feed last read ${firm.cpe} longer ago than its age limit: the absence of a match is not a clean result` : `the live advisory feed never read ${firm.cpe} (it was not queried or the query did not complete): the absence of a match is not a clean result`;
|
|
374
|
+
} else if (!matched && !cpeCands.length) covGap = 'the live advisory feed is keyed by CPE and this component declares no CPE identity (a source URL or purl alone is not something it can look up): the absence of a match is not a clean result';
|
|
375
|
+
}
|
|
323
376
|
if (matched) summary.matched++;
|
|
377
|
+
if (covGap) { if (!matched) summary.unmapped++; push({ status: 'unknown', reason: covGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
|
|
378
|
+
else if (matched) { /* findings above are the result */ }
|
|
324
379
|
else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
|
|
325
380
|
else if (mapped) { summary.mappedNoMatch++; push({ status: 'not-affected', reason: 'mapped to an upstream identity and no advisory in the supplied feed matches this version' }, { identityMapped: true }); }
|
|
326
381
|
else { summary.unmapped++; push({ status: 'unknown', reason: ident.ambiguous ? 'the upstream identity is ambiguous: no verdict' : 'the upstream identity could not be mapped to anything an advisory is keyed by' }, { identityMapped: false }); }
|
|
@@ -340,10 +395,14 @@ function finding(base, adv, status, reason, extra, { kev, epss }) {
|
|
|
340
395
|
const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
|
|
341
396
|
const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
|
|
342
397
|
const level = { affected: 'high', 'possibly-affected': 'medium', candidate: 'low', 'backported-verified': 'info', fixed: 'info', unknown: 'low', 'not-affected': 'info' }[status] || 'low';
|
|
398
|
+
// The advisory's own rating applies only where the package is judged vulnerable; the other statuses keep their fixed,
|
|
399
|
+
// lower levels (a verified backport must never be raised by the upstream score).
|
|
400
|
+
const sev = adv.severityInfo || { level: null, score: null, basis: NO_RATING_BASIS };
|
|
401
|
+
const rated = !!sev.level && (status === 'affected' || status === 'possibly-affected');
|
|
343
402
|
return {
|
|
344
403
|
type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
|
|
345
404
|
name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
|
|
346
|
-
status, matchStatus: status, matchReason: reason, severity: level, severityBasis:
|
|
405
|
+
status, matchStatus: status, matchReason: reason, severity: rated ? sev.level : level, severityBasis: rated ? sev.basis : sev.level ? `the match status "${status}" fixes this level; the advisory rating (${sev.basis}) is not applied` : sev.basis, ...(rated ? { severityScore: sev.score } : {}),
|
|
347
406
|
nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
|
|
348
407
|
kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
|
|
349
408
|
fixedIn: extra.fixedIn || [], patchEvidence: extra.patchEvidence || { verified: null, claims: [] }, dataSource: { feed: extra.source || base.feed.source, ecosystem: extra.ecosystem || null, identityBasis: extra.identityBasis || null, identityAuthority: extra.identityAuthority || null, feedStatus: base.feed.status },
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Pure pieces of the bounded NixOS option evaluator (NIX-002): value classes, equality, and the
|
|
2
|
+
// closed table of library functions the evaluator is willing to run.
|
|
3
|
+
//
|
|
4
|
+
// The evaluator (nixos-module-resolver.js) is a SOUNDNESS-first subset: a value is either fully known
|
|
5
|
+
// or unknown, and any unknown part makes the whole value unknown. This file is the allow-list. A
|
|
6
|
+
// function that is not in these tables is never run, so `import`, `fetch*`, `readFile`, `toFile`,
|
|
7
|
+
// `currentSystem`, `getEnv`, derivations and every other effectful or environment-dependent primitive
|
|
8
|
+
// are unknown by construction (there is no deny-list to forget an entry in).
|
|
9
|
+
|
|
10
|
+
/** Thrown by a table function (and caught by the resolver) when its arguments are not statically usable. */
|
|
11
|
+
export const NOT_STATIC = Object.freeze({ notStatic: true });
|
|
12
|
+
|
|
13
|
+
/** A lambda captured with its definition environment. Never a final option value. */
|
|
14
|
+
export class Closure {
|
|
15
|
+
constructor(param, body, env) { this.param = param; this.body = body; this.env = env; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A table function, possibly partially applied. Never a final option value. */
|
|
19
|
+
export class Builtin {
|
|
20
|
+
constructor(name, def, args = []) { this.name = name; this.def = def; this.args = args; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A namespace value (`lib`, `builtins`, `lib.strings`, `lib.lists`). Never a final option value. */
|
|
24
|
+
export class Ns {
|
|
25
|
+
constructor(kind) { this.kind = kind; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isAttrs = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
29
|
+
export const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
30
|
+
const isInt = (v) => typeof v === 'number' && Number.isSafeInteger(v);
|
|
31
|
+
|
|
32
|
+
/** Own-property assignment that is safe for the attribute name `__proto__`. */
|
|
33
|
+
export function setAttr(obj, key, value) {
|
|
34
|
+
Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when a value contains only data (no closure, partial application or namespace). Bounded. */
|
|
38
|
+
export function isPlain(v, budget = { n: 20_000 }, depth = 0) {
|
|
39
|
+
if (--budget.n < 0 || depth > 48) return false;
|
|
40
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean' || typeof v === 'number') return true;
|
|
41
|
+
if (Array.isArray(v)) return v.every((x) => isPlain(x, budget, depth + 1));
|
|
42
|
+
if (isAttrs(v)) return Object.keys(v).every((k) => isPlain(v[k], budget, depth + 1));
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Nix `==`: true, false, or null when it cannot be decided statically (functions are never compared). */
|
|
47
|
+
export function deepEq(a, b, depth = 0) {
|
|
48
|
+
if (depth > 48) return null;
|
|
49
|
+
const fnA = a instanceof Closure || a instanceof Builtin || a instanceof Ns;
|
|
50
|
+
const fnB = b instanceof Closure || b instanceof Builtin || b instanceof Ns;
|
|
51
|
+
if (fnA || fnB) return null;
|
|
52
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
53
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
54
|
+
if (a.length !== b.length) return false;
|
|
55
|
+
let unknown = false;
|
|
56
|
+
for (let i = 0; i < a.length; i++) { const r = deepEq(a[i], b[i], depth + 1); if (r === false) return false; if (r === null) unknown = true; }
|
|
57
|
+
return unknown ? null : true;
|
|
58
|
+
}
|
|
59
|
+
if (isAttrs(a) || isAttrs(b)) {
|
|
60
|
+
if (!isAttrs(a) || !isAttrs(b)) return false;
|
|
61
|
+
const ka = Object.keys(a); const kb = Object.keys(b);
|
|
62
|
+
if (ka.length !== kb.length || !ka.every((k) => hasOwn(b, k))) return false;
|
|
63
|
+
let unknown = false;
|
|
64
|
+
for (const k of ka) { const r = deepEq(a[k], b[k], depth + 1); if (r === false) return false; if (r === null) unknown = true; }
|
|
65
|
+
return unknown ? null : true;
|
|
66
|
+
}
|
|
67
|
+
return a === b;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const need = (cond) => { if (!cond) throw NOT_STATIC; };
|
|
71
|
+
const MAX_STRING = 65_536;
|
|
72
|
+
|
|
73
|
+
function toStr(v) {
|
|
74
|
+
if (typeof v === 'string') return v;
|
|
75
|
+
if (v === null || v === false) return '';
|
|
76
|
+
if (v === true) return '1';
|
|
77
|
+
if (isInt(v)) return String(v);
|
|
78
|
+
throw NOT_STATIC;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Each entry: arity and a function over the evaluated arguments. `c.apply(f, x)` runs a closure or table
|
|
82
|
+
// function and throws NOT_STATIC if it is not statically known; `c.size(n)` enforces the size cap.
|
|
83
|
+
const DEFS = {
|
|
84
|
+
map: { arity: 2, call: ([f, xs], c) => { need(Array.isArray(xs)); c.size(xs.length); return xs.map((x) => c.apply(f, x)); } },
|
|
85
|
+
filter: {
|
|
86
|
+
arity: 2,
|
|
87
|
+
call: ([f, xs], c) => { need(Array.isArray(xs)); c.size(xs.length); return xs.filter((x) => { const r = c.apply(f, x); need(typeof r === 'boolean'); return r; }); },
|
|
88
|
+
},
|
|
89
|
+
elem: {
|
|
90
|
+
arity: 2,
|
|
91
|
+
call: ([x, xs]) => {
|
|
92
|
+
need(Array.isArray(xs));
|
|
93
|
+
let unknown = false;
|
|
94
|
+
for (const y of xs) { const r = deepEq(x, y); if (r === true) return true; if (r === null) unknown = true; }
|
|
95
|
+
need(!unknown);
|
|
96
|
+
return false;
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
length: { arity: 1, call: ([xs]) => { need(Array.isArray(xs)); return xs.length; } },
|
|
100
|
+
concatLists: {
|
|
101
|
+
arity: 1,
|
|
102
|
+
call: ([xss], c) => { need(Array.isArray(xss) && xss.every(Array.isArray)); const out = [].concat(...xss); c.size(out.length); return out; },
|
|
103
|
+
},
|
|
104
|
+
concatMap: {
|
|
105
|
+
arity: 2,
|
|
106
|
+
call: ([f, xs], c) => {
|
|
107
|
+
need(Array.isArray(xs)); c.size(xs.length);
|
|
108
|
+
const parts = xs.map((x) => { const r = c.apply(f, x); need(Array.isArray(r)); return r; });
|
|
109
|
+
const out = [].concat(...parts); c.size(out.length); return out;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
hasAttr: { arity: 2, call: ([name, set]) => { need(typeof name === 'string' && isAttrs(set)); return hasOwn(set, name); } },
|
|
113
|
+
toString: { arity: 1, call: ([v]) => toStr(v) },
|
|
114
|
+
optionals: { arity: 2, call: ([cond, xs]) => { need(typeof cond === 'boolean' && Array.isArray(xs)); return cond ? xs : []; } },
|
|
115
|
+
optional: { arity: 2, call: ([cond, x]) => { need(typeof cond === 'boolean'); return cond ? [x] : []; } },
|
|
116
|
+
optionalAttrs: { arity: 2, call: ([cond, set]) => { need(typeof cond === 'boolean' && isAttrs(set)); return cond ? set : {}; } },
|
|
117
|
+
optionalString: { arity: 2, call: ([cond, s]) => { need(typeof cond === 'boolean' && typeof s === 'string'); return cond ? s : ''; } },
|
|
118
|
+
concatStringsSep: {
|
|
119
|
+
arity: 2,
|
|
120
|
+
call: ([sep, xs], c) => {
|
|
121
|
+
need(typeof sep === 'string' && Array.isArray(xs) && xs.every((x) => typeof x === 'string')); c.size(xs.length);
|
|
122
|
+
const out = xs.join(sep); need(out.length <= MAX_STRING); return out;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
concatMapStringsSep: {
|
|
126
|
+
arity: 3,
|
|
127
|
+
call: ([sep, f, xs], c) => {
|
|
128
|
+
need(typeof sep === 'string' && Array.isArray(xs)); c.size(xs.length);
|
|
129
|
+
const parts = xs.map((x) => { const r = c.apply(f, x); need(typeof r === 'string'); return r; });
|
|
130
|
+
const out = parts.join(sep); need(out.length <= MAX_STRING); return out;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
hasPrefix: { arity: 2, call: ([p, s]) => { need(typeof p === 'string' && typeof s === 'string'); return s.startsWith(p); } },
|
|
134
|
+
hasSuffix: { arity: 2, call: ([p, s]) => { need(typeof p === 'string' && typeof s === 'string'); return s.endsWith(p); } },
|
|
135
|
+
boolToString: { arity: 1, call: ([b]) => { need(typeof b === 'boolean'); return b ? 'true' : 'false'; } },
|
|
136
|
+
// Only a merge of plain lists is a plain list; any other element (an attribute set, a priority or condition
|
|
137
|
+
// wrapper, an unknown) cannot be merged without the option's type, so it is not static.
|
|
138
|
+
mkMerge: { arity: 1, call: ([xss], c) => { need(Array.isArray(xss) && xss.every(Array.isArray)); const out = [].concat(...xss); c.size(out.length); return out; } },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const LIB_NAMES = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'optionals', 'optional', 'optionalAttrs', 'optionalString',
|
|
142
|
+
'concatStringsSep', 'concatMapStringsSep', 'hasPrefix', 'hasSuffix', 'boolToString', 'mkMerge']);
|
|
143
|
+
const LIB_STRINGS = new Set(['concatStringsSep', 'concatMapStringsSep', 'hasPrefix', 'hasSuffix', 'optionalString']);
|
|
144
|
+
const LIB_LISTS = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'optionals', 'optional']);
|
|
145
|
+
const BUILTIN_NAMES = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'hasAttr', 'toString']);
|
|
146
|
+
/** Names a Nix program has without any import (the ones we model). */
|
|
147
|
+
const GLOBAL_NAMES = new Set(['map', 'toString']);
|
|
148
|
+
|
|
149
|
+
const SETS = { lib: LIB_NAMES, builtins: BUILTIN_NAMES, 'lib.strings': LIB_STRINGS, 'lib.lists': LIB_LISTS };
|
|
150
|
+
|
|
151
|
+
/** Resolves `<ns>.<name>`: a table function, a sub-namespace, or undefined (not modelled). */
|
|
152
|
+
export function nsLookup(kind, name) {
|
|
153
|
+
if (kind === 'lib' && name === 'strings') return new Ns('lib.strings');
|
|
154
|
+
if (kind === 'lib' && name === 'lists') return new Ns('lib.lists');
|
|
155
|
+
const set = SETS[kind];
|
|
156
|
+
if (!set || !set.has(name)) return undefined;
|
|
157
|
+
return new Builtin(name, DEFS[name]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const isGlobalName = (name) => GLOBAL_NAMES.has(name);
|
|
161
|
+
export const globalValue = (name) => (name === 'builtins' ? new Ns('builtins') : nsLookup('builtins', name));
|
|
162
|
+
export const MODELLED = Object.freeze({ lib: [...LIB_NAMES].sort(), builtins: [...BUILTIN_NAMES].sort() });
|