@intentius/chant 0.124.45 → 0.124.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities/aws.paths +148 -0
- package/dist/capabilities/base-mappings.json +122 -0
- package/dist/capabilities/catalog.d.ts +89 -0
- package/dist/capabilities/catalog.d.ts.map +1 -0
- package/dist/capabilities/catalog.js +204 -0
- package/dist/capabilities/language.d.ts +103 -0
- package/dist/capabilities/language.d.ts.map +1 -0
- package/dist/capabilities/language.js +335 -0
- package/dist/capabilities/matcher.d.ts +65 -0
- package/dist/capabilities/matcher.d.ts.map +1 -0
- package/dist/capabilities/matcher.js +299 -0
- package/dist/capabilities/view.d.ts +207 -0
- package/dist/capabilities/view.d.ts.map +1 -0
- package/dist/capabilities/view.js +484 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +1 -0
- package/dist/cli/handlers/declared-graph.d.ts +44 -0
- package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
- package/dist/cli/handlers/declared-graph.js +87 -0
- package/dist/cli/handlers/graph.d.ts +25 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +93 -87
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +2 -1
- package/dist/cli/handlers/search-count.d.ts +89 -0
- package/dist/cli/handlers/search-count.d.ts.map +1 -0
- package/dist/cli/handlers/search-count.js +132 -0
- package/dist/cli/handlers/search.d.ts +358 -6
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/handlers/search.js +1065 -155
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +23 -3
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/server.js +3 -0
- package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
- package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
- package/dist/cli/mcp/tools/capabilities.js +27 -0
- package/dist/cli/mcp/workspace-tools.d.ts +3 -1
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +47 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +64 -17
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-env-ports.d.ts +29 -0
- package/dist/components/pr-env-ports.d.ts.map +1 -0
- package/dist/components/pr-env-ports.js +77 -0
- package/dist/components/pr-env-run.d.ts +5 -21
- package/dist/components/pr-env-run.d.ts.map +1 -1
- package/dist/components/pr-env-run.js +6 -70
- package/dist/components/pr-environment.d.ts +2 -1
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +1 -1
- package/dist/config-environments.d.ts +92 -0
- package/dist/config-environments.d.ts.map +1 -0
- package/dist/config-environments.js +102 -0
- package/dist/config.d.ts +24 -84
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -96
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +15 -3
- package/dist/graph-effective.d.ts +33 -2
- package/dist/graph-effective.d.ts.map +1 -1
- package/dist/graph-effective.js +215 -68
- package/dist/graph-ir.d.ts +23 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +130 -0
- package/dist/graph-sub-template.d.ts +11 -0
- package/dist/graph-sub-template.d.ts.map +1 -0
- package/dist/graph-sub-template.js +19 -0
- package/dist/lexicon.d.ts +9 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +28 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/observe.js +23 -1
- package/dist/lint/rule.d.ts +6 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +1 -1
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +1 -1
- package/dist/observation.d.ts +48 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +21 -5
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/wave-runtime.mjs +250 -249
- package/dist/workspace/capabilities-contract.d.ts +66 -0
- package/dist/workspace/capabilities-contract.d.ts.map +1 -0
- package/dist/workspace/capabilities-contract.js +107 -0
- package/dist/workspace/capabilities.schema.json +229 -0
- package/dist/workspace/compose-graph.d.ts +9 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +3 -1
- package/dist/workspace/conformance/index.d.ts +1 -1
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/index.js +39 -2
- package/dist/workspace/graph-cache.d.ts +8 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +27 -3
- package/dist/workspace/graph-cli.d.ts +39 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/graph-cli.js +66 -24
- package/dist/workspace/graph.schema.json +148 -0
- package/dist/workspace/kind-readers.d.ts +3 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +7 -1
- package/dist/workspace/member-commands.d.ts +10 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +17 -1
- package/dist/workspace/nested-graph.d.ts +2 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +32 -1
- package/dist/workspace/reason-codes.d.ts +10 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +12 -0
- package/dist/workspace/search-cli.d.ts +204 -0
- package/dist/workspace/search-cli.d.ts.map +1 -0
- package/dist/workspace/search-cli.js +466 -0
- package/dist/workspace/search-contract.d.ts +56 -0
- package/dist/workspace/search-contract.d.ts.map +1 -0
- package/dist/workspace/search-contract.js +70 -0
- package/dist/workspace/search.schema.json +822 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +10 -0
- package/src/capabilities/aws.paths +148 -0
- package/src/capabilities/base-mappings.json +122 -0
- package/src/capabilities/catalog.ts +230 -0
- package/src/capabilities/language.test.ts +191 -0
- package/src/capabilities/language.ts +373 -0
- package/src/capabilities/matcher.ts +315 -0
- package/src/capabilities/view-compose.test.ts +76 -0
- package/src/capabilities/view.ts +631 -0
- package/src/cli/commands/lint.ts +1 -0
- package/src/cli/handlers/declared-graph.ts +99 -0
- package/src/cli/handlers/graph.test.ts +94 -3
- package/src/cli/handlers/graph.ts +123 -96
- package/src/cli/handlers/pr-env.ts +1 -2
- package/src/cli/handlers/search-build-roots.test.ts +144 -0
- package/src/cli/handlers/search-count.test.ts +191 -0
- package/src/cli/handlers/search-count.ts +165 -0
- package/src/cli/handlers/search-json.test.ts +333 -0
- package/src/cli/handlers/search-live.test.ts +387 -0
- package/src/cli/handlers/search-paths.test.ts +235 -0
- package/src/cli/handlers/search.ts +1312 -166
- package/src/cli/main.test.ts +28 -3
- package/src/cli/main.ts +23 -3
- package/src/cli/mcp/server.test.ts +3 -3
- package/src/cli/mcp/server.ts +3 -0
- package/src/cli/mcp/tools/capabilities.ts +32 -0
- package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
- package/src/cli/mcp/workspace-tools.ts +47 -2
- package/src/cli/parse-args.ts +52 -13
- package/src/cli/registry.ts +22 -0
- package/src/components/pr-env-ports.ts +89 -0
- package/src/components/pr-env-run-bundle.test.ts +54 -0
- package/src/components/pr-env-run.ts +5 -76
- package/src/components/pr-environment.ts +2 -1
- package/src/config-environments.ts +133 -0
- package/src/config.ts +37 -124
- package/src/graph-detail.ts +15 -3
- package/src/graph-effective.ts +226 -75
- package/src/graph-ir.test.ts +163 -0
- package/src/graph-ir.ts +147 -0
- package/src/graph-sub-template.ts +22 -0
- package/src/lexicon.ts +10 -0
- package/src/lifecycle/observe.test.ts +27 -1
- package/src/lifecycle/observe.ts +43 -1
- package/src/lint/rule.ts +6 -0
- package/src/live-endpoint.ts +1 -1
- package/src/observation.test.ts +25 -0
- package/src/observation.ts +69 -5
- package/src/workspace/capabilities-contract.test.ts +88 -0
- package/src/workspace/capabilities-contract.ts +132 -0
- package/src/workspace/capabilities.schema.json +229 -0
- package/src/workspace/compose-graph.ts +10 -1
- package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
- package/src/workspace/conformance/index.ts +31 -2
- package/src/workspace/graph-cache.ts +27 -3
- package/src/workspace/graph-cli.ts +96 -24
- package/src/workspace/graph.schema.json +148 -0
- package/src/workspace/kind-readers.e2e.test.ts +28 -0
- package/src/workspace/kind-readers.test.ts +1 -1
- package/src/workspace/kind-readers.ts +7 -1
- package/src/workspace/member-commands.ts +22 -1
- package/src/workspace/nested-graph.ts +32 -1
- package/src/workspace/reason-codes.test.ts +7 -0
- package/src/workspace/reason-codes.ts +12 -0
- package/src/workspace/search-cli.e2e.test.ts +305 -0
- package/src/workspace/search-cli.ts +591 -0
- package/src/workspace/search-contract.ts +95 -0
- package/src/workspace/search.schema.json +822 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability rule language and its matcher (#4375), over hand-built
|
|
3
|
+
* views: what each construct means, independent of any lexicon.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { parseRules, RuleSyntaxError } from "./language";
|
|
7
|
+
import { CapabilityEngine, CapabilityError } from "./matcher";
|
|
8
|
+
import { isPublic, lexiconSupport, parseCapabilityCall, shippedRules } from "./catalog";
|
|
9
|
+
import type { View, ViewNode } from "./view";
|
|
10
|
+
|
|
11
|
+
function view(nodes: [string, string, Record<string, unknown>?][], edges: [string, string, string][]): View {
|
|
12
|
+
const ns = new Map<string, ViewNode>(
|
|
13
|
+
nodes.map(([id, label, attrs]) => [id, { id, label, irId: id, lexicon: "test", attrs: attrs ?? {} }]),
|
|
14
|
+
);
|
|
15
|
+
return {
|
|
16
|
+
nodes: ns,
|
|
17
|
+
edges: edges.map(([from, name, to]) => ({ from, to, name, hops: [{ from, to, edge: name }] })),
|
|
18
|
+
lexicons: new Set(["test"]),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const keys = (m: Map<string, unknown>) => [...m.keys()].sort();
|
|
23
|
+
const pairs = (rel: { out: Map<string, Map<string, unknown>> }) =>
|
|
24
|
+
[...rel.out].flatMap(([a, m]) => [...m.keys()].map((b) => `${a}>${b}`)).sort();
|
|
25
|
+
|
|
26
|
+
describe("parseRules", () => {
|
|
27
|
+
it("reads sets, edges, parameters, alternatives, continuation lines and metadata", () => {
|
|
28
|
+
const rules = parseRules(`
|
|
29
|
+
# a comment
|
|
30
|
+
## meaning: a thing
|
|
31
|
+
## answers: one
|
|
32
|
+
## answers: two
|
|
33
|
+
thing(x) := x:A|B[size >= 2, !gone]
|
|
34
|
+
| x:C
|
|
35
|
+
link(a, b; p?, q) := a ->{0,1}via ->(e|f) b:B[n = $p] !<-blocked
|
|
36
|
+
long(x) :=
|
|
37
|
+
x:A
|
|
38
|
+
`);
|
|
39
|
+
expect(rules.map((r) => [r.name, r.vars, r.params, r.alts.length])).toEqual([
|
|
40
|
+
["thing", ["x"], [], 2],
|
|
41
|
+
["link", ["a", "b"], [{ name: "p", optional: true }, { name: "q", optional: false }], 1],
|
|
42
|
+
["long", ["x"], [], 1],
|
|
43
|
+
]);
|
|
44
|
+
expect(rules[0].meta).toEqual({ meaning: "a thing", answers: "one\ntwo" });
|
|
45
|
+
expect(rules[0].text).toBe("thing(x) := x:A|B[size >= 2, !gone]\n| x:C");
|
|
46
|
+
const steps = rules[1].alts[0].steps;
|
|
47
|
+
expect(steps.map((s) => [s.dir, s.negated, s.names.map((n) => n.name), s.min, s.max])).toEqual([
|
|
48
|
+
["out", false, ["via"], 0, 1],
|
|
49
|
+
["out", false, ["e", "f"], 1, 1],
|
|
50
|
+
["in", true, ["blocked"], 1, 1],
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("refuses what it can't read, naming the line", () => {
|
|
55
|
+
expect(() => parseRules("a(x) := x:A\nb(x) := x[n ?? 1]")).toThrow(RuleSyntaxError);
|
|
56
|
+
expect(() => parseRules("a(x) := x:A\nb(x) := x[n ?? 1]")).toThrow(/line 2/);
|
|
57
|
+
expect(() => parseRules("| x:A")).toThrow(/needs a rule above it/);
|
|
58
|
+
expect(() => parseRules("a(x) := x:A\na(x) := x:B")).toThrow(/defined twice/);
|
|
59
|
+
expect(() => parseRules("a(x) := x !->{0,2}e")).toThrow(/negated step takes no depth/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads a capability call as search spells it", () => {
|
|
63
|
+
expect(parseCapabilityCall("open-ingress(22, tcp)")).toEqual({ name: "open-ingress", args: [22, "tcp"] });
|
|
64
|
+
expect(parseCapabilityCall("grants(s3:GetObject)")).toEqual({ name: "grants", args: ["s3:GetObject"] });
|
|
65
|
+
expect(parseCapabilityCall("reaches")).toEqual({ name: "reaches", args: [] });
|
|
66
|
+
expect(parseCapabilityCall("bad name")).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("the matcher", () => {
|
|
71
|
+
const v = view(
|
|
72
|
+
[
|
|
73
|
+
["a", "A", { size: 3 }],
|
|
74
|
+
["b", "A", { size: 1 }],
|
|
75
|
+
["c", "B", { tags: ["x", "y"] }],
|
|
76
|
+
["d", "B"],
|
|
77
|
+
["e", "B"],
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
["a", "to", "c"],
|
|
81
|
+
["c", "to", "d"],
|
|
82
|
+
["d", "to", "e"],
|
|
83
|
+
["b", "to", "d"],
|
|
84
|
+
["e", "blocks", "d"],
|
|
85
|
+
],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
it("tests attributes, lists and labels", () => {
|
|
89
|
+
const e = new CapabilityEngine(v, parseRules(`
|
|
90
|
+
big(x) := x:A[size >= 2]
|
|
91
|
+
tagged(x) := x[tags in ("y")]
|
|
92
|
+
absent(x) := x:B[!tags]
|
|
93
|
+
`));
|
|
94
|
+
expect(keys(e.set("big"))).toEqual(["a"]);
|
|
95
|
+
expect(keys(e.set("tagged"))).toEqual(["c"]);
|
|
96
|
+
expect(keys(e.set("absent"))).toEqual(["d", "e"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("walks a depth range, keeping the hops it took", () => {
|
|
100
|
+
const e = new CapabilityEngine(v, parseRules(`
|
|
101
|
+
near(a, b) := a:A ->to b
|
|
102
|
+
far(a, b) := a:A ->{2,} to b
|
|
103
|
+
`));
|
|
104
|
+
expect(pairs(e.relation("near"))).toEqual(["a>c", "b>d"]);
|
|
105
|
+
expect(pairs(e.relation("far"))).toEqual(["a>d", "a>e", "b>e"]);
|
|
106
|
+
expect(e.relation("far").out.get("a")!.get("e")!.hops.map((h) => `${h.from}${h.to}`)).toEqual(["ac", "cd", "de"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("negates a step and a capability test, and reads a capability that uses another", () => {
|
|
110
|
+
const e = new CapabilityEngine(v, parseRules(`
|
|
111
|
+
unblocked(x) := x:B !<-blocks
|
|
112
|
+
big(x) := x:A[size >= 2]
|
|
113
|
+
small-reach(a, b) := a:A[!@big] ->{1,} to b[@unblocked]
|
|
114
|
+
`));
|
|
115
|
+
expect(keys(e.set("unblocked"))).toEqual(["c", "e"]);
|
|
116
|
+
expect(pairs(e.relation("small-reach"))).toEqual(["b>e"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("leaves out a test on a parameter nobody passed, and applies it when one is", () => {
|
|
120
|
+
const e = new CapabilityEngine(v, parseRules(`sized(x; min?) := x:A[size >= $min]`));
|
|
121
|
+
expect(keys(e.set("sized"))).toEqual(["a", "b"]);
|
|
122
|
+
expect(keys(e.set("sized", e.bind("sized", [2])))).toEqual(["a"]);
|
|
123
|
+
expect(() => e.bind("sized", [1, 2])).toThrow(/at most 1/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("closes a pattern on a variable it bound earlier", () => {
|
|
127
|
+
const loop = view([["p", "A"], ["q", "A"], ["r", "A"]], [["p", "to", "q"], ["q", "back", "p"], ["q", "back", "r"]]);
|
|
128
|
+
const e = new CapabilityEngine(loop, parseRules(`mutual(a, b) := a ->to b ->back a`));
|
|
129
|
+
expect(pairs(e.relation("mutual"))).toEqual(["p>q"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("carries the evidence of each set a match relied on", () => {
|
|
133
|
+
const e = new CapabilityEngine(v, parseRules(`
|
|
134
|
+
big(x) := x:A[size >= 2]
|
|
135
|
+
from-big(a, b) := a[@big] ->to b
|
|
136
|
+
`));
|
|
137
|
+
const ev = e.relation("from-big").out.get("a")!.get("c")!;
|
|
138
|
+
expect(ev.because.map((b) => [b.capability, b.id])).toEqual([["big", "a"]]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("refuses a capability that needs itself, an unknown one, and one used in the wrong shape", () => {
|
|
142
|
+
expect(() => new CapabilityEngine(v, parseRules(`a(x) := x[@nope]`))).toThrow(/no capability "nope"/);
|
|
143
|
+
expect(() => new CapabilityEngine(v, parseRules(`e(a, b) := a ->to b\ns(x) := x[@e]`))).toThrow(/is an edge, not a set/);
|
|
144
|
+
const self = new CapabilityEngine(v, parseRules(`loop(x) := x[@loop]`));
|
|
145
|
+
expect(() => self.set("loop")).toThrow(CapabilityError);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("the shipped catalog", () => {
|
|
150
|
+
const { rules } = shippedRules();
|
|
151
|
+
const pub = rules.filter(isPublic);
|
|
152
|
+
|
|
153
|
+
it("lists the 15 capabilities of the #4361 ruling (grants and reaches are one row)", () => {
|
|
154
|
+
expect(pub.map((r) => r.name)).toEqual([
|
|
155
|
+
"internet-facing",
|
|
156
|
+
"open-ingress",
|
|
157
|
+
"internet-reachable",
|
|
158
|
+
"attached-to-nothing",
|
|
159
|
+
"in-default-vpc",
|
|
160
|
+
"network",
|
|
161
|
+
"stores-data",
|
|
162
|
+
"holds-secret",
|
|
163
|
+
"encrypted-at-rest",
|
|
164
|
+
"publicly-readable",
|
|
165
|
+
"runs-as",
|
|
166
|
+
"can-assume",
|
|
167
|
+
"grants",
|
|
168
|
+
"reaches",
|
|
169
|
+
"exfiltrates",
|
|
170
|
+
"blast",
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("gives each a meaning, at least one answer and a depth", () => {
|
|
175
|
+
for (const r of pub) {
|
|
176
|
+
expect(r.meta.meaning, r.name).toBeTruthy();
|
|
177
|
+
expect(r.meta.answers, r.name).toBeTruthy();
|
|
178
|
+
expect(["declared", "live", "either"], r.name).toContain(r.meta.depth);
|
|
179
|
+
for (const p of r.params) expect(r.meta[`param-${p.name}`], `${r.name} ${p.name}`).toBeTruthy();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("says a lexicon with no mapping can't supply any of them, and why a thin mapping can't", () => {
|
|
184
|
+
const rule = pub.find((r) => r.name === "grants")!;
|
|
185
|
+
expect(lexiconSupport(rule, undefined, "k8s")).toEqual({ code: "lexicon-has-no-mapping", message: "the k8s lexicon ships no capability mapping" });
|
|
186
|
+
const thin = lexiconSupport(rule, { lexicon: "x", labels: { "X::Bucket": "Bucket" } }, "x");
|
|
187
|
+
expect(thin).toMatchObject({ code: "capability-not-derivable" });
|
|
188
|
+
expect(lexiconSupport(rule, { lexicon: "x", labels: { "X::Role": "Role", "X::Policy": "Policy" } }, "x")).toBe("supported");
|
|
189
|
+
expect(lexiconSupport(rule, { lexicon: "x", unsupported: { grants: { code: "c", message: "m" } } }, "x")).toEqual({ code: "c", message: "m" });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability rule language (#4375, ruled on #4361).
|
|
3
|
+
*
|
|
4
|
+
* A capability is a named path pattern over the lexicon-neutral view
|
|
5
|
+
* (`./view.ts`). Its steps are #4359's edge terms: an arrow, an optional
|
|
6
|
+
* depth, then what the edge is. A rule file is data, read at run time:
|
|
7
|
+
*
|
|
8
|
+
* # a comment
|
|
9
|
+
* ## meaning: what the capability says, in one line (catalog metadata)
|
|
10
|
+
* in-sg(x, g) := x ->{0,1}template ->sg g:SecurityGroup
|
|
11
|
+
* open-ingress(c; port?, proto?)
|
|
12
|
+
* := c ->in-sg ->ingress :IngressRule[cidrs in ("0.0.0.0/0"), from <= $port]
|
|
13
|
+
* internet-reachable(c; port?)
|
|
14
|
+
* := c[@internet-facing, @open-ingress($port)]
|
|
15
|
+
* | c:Function <-function :FunctionUrl[authType = "NONE"]
|
|
16
|
+
*
|
|
17
|
+
* One variable makes a node set, used as `[@name]`. Two make a named edge,
|
|
18
|
+
* followed as `->name` or `<-name`, which carries the hops that proved it.
|
|
19
|
+
* Parameters come after `;`; a `?` makes one optional, and a test that names
|
|
20
|
+
* an absent parameter is left out. Lines starting `|` add an alternative.
|
|
21
|
+
*
|
|
22
|
+
* A step is `->` or `<-`, an optional depth (`?`, `*`, `+`, `{n}`, `{m,n}`,
|
|
23
|
+
* `{m,}`; a rule may start at 0, which #4359's query grammar refuses), an
|
|
24
|
+
* edge name or `(a|b)`, and an optional node: `var:Label|Label[tests]`.
|
|
25
|
+
* `!->name node` and `!<-name node` are negated steps: the walk stays where
|
|
26
|
+
* it is and goes on only when no such edge leads to such a node.
|
|
27
|
+
*
|
|
28
|
+
* A node test is `attr = v`, `attr != v`, `attr in (v, ...)`, `attr ~ "glob"`,
|
|
29
|
+
* `attr < n` (and `<=`, `>`, `>=`), a bare `attr` (present and not false),
|
|
30
|
+
* or `@cap` / `@cap(args)`. Any test takes a leading `!`. Against a list
|
|
31
|
+
* attribute, a test holds when it holds for any element.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type Literal = string | number | boolean | null;
|
|
35
|
+
|
|
36
|
+
/** A value in a test or an argument: a literal, or a reference to a parameter. */
|
|
37
|
+
export type Value = { lit: Literal } | { param: string };
|
|
38
|
+
|
|
39
|
+
export type Test =
|
|
40
|
+
| { kind: "attr"; negated: boolean; attr: string; op: "=" | "!=" | "in" | "~" | "<" | "<=" | ">" | ">=" | "present"; values: Value[] }
|
|
41
|
+
| { kind: "cap"; negated: boolean; cap: string; args: Value[] };
|
|
42
|
+
|
|
43
|
+
export interface NodeSpec {
|
|
44
|
+
v?: string;
|
|
45
|
+
labels?: string[];
|
|
46
|
+
tests: Test[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Step {
|
|
50
|
+
dir: "out" | "in";
|
|
51
|
+
negated: boolean;
|
|
52
|
+
/** Edge names: view edges or two-variable capabilities. */
|
|
53
|
+
names: { name: string; args: Value[] }[];
|
|
54
|
+
min: number;
|
|
55
|
+
max: number;
|
|
56
|
+
node: NodeSpec;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Alternative {
|
|
60
|
+
head: NodeSpec;
|
|
61
|
+
steps: Step[];
|
|
62
|
+
/** 1-based line in the rule file, for messages. */
|
|
63
|
+
line: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Param {
|
|
67
|
+
name: string;
|
|
68
|
+
optional: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Rule {
|
|
72
|
+
name: string;
|
|
73
|
+
vars: string[];
|
|
74
|
+
params: Param[];
|
|
75
|
+
alts: Alternative[];
|
|
76
|
+
/** `## key: value` lines above the rule. */
|
|
77
|
+
meta: Record<string, string>;
|
|
78
|
+
line: number;
|
|
79
|
+
/** The rule as written, one alternative per line, comments dropped. */
|
|
80
|
+
text: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A rule file the parser cannot read, with the line it failed on. */
|
|
84
|
+
export class RuleSyntaxError extends Error {
|
|
85
|
+
constructor(
|
|
86
|
+
message: string,
|
|
87
|
+
readonly line: number,
|
|
88
|
+
) {
|
|
89
|
+
super(`line ${line}: ${message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// An identifier may hold `-` (`can-assume`), but not one that starts an arrow.
|
|
94
|
+
const TOKEN = /\s*(!->|!<-|->|<-|:=|<=|>=|!=|\{\d*(?:,\d*)?\}|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?![\w-])|[$]?[A-Za-z_](?:\w|-(?!>))*|[()[\],|?*+@:=~<>!;])/y;
|
|
95
|
+
|
|
96
|
+
function tokenize(text: string, line: number): string[] {
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
TOKEN.lastIndex = 0;
|
|
99
|
+
const end = text.trimEnd().length;
|
|
100
|
+
while (TOKEN.lastIndex < end) {
|
|
101
|
+
const at = TOKEN.lastIndex;
|
|
102
|
+
const m = TOKEN.exec(text);
|
|
103
|
+
if (!m) throw new RuleSyntaxError(`cannot read "${text.slice(at).trim()}"`, line);
|
|
104
|
+
out.push(m[1]);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse a rule file. Rules may span lines: a line that starts with `|`
|
|
111
|
+
* continues the rule above it, and so does an indented line when the rule's
|
|
112
|
+
* `:=` has nothing after it yet.
|
|
113
|
+
*/
|
|
114
|
+
export function parseRules(text: string): Rule[] {
|
|
115
|
+
const rules: Rule[] = [];
|
|
116
|
+
let meta: Record<string, string> = {};
|
|
117
|
+
let pending: { text: string; line: number; rule?: Rule } | undefined;
|
|
118
|
+
const lines = text.split("\n");
|
|
119
|
+
|
|
120
|
+
const flush = (): void => {
|
|
121
|
+
if (!pending) return;
|
|
122
|
+
const { text: body, line } = pending;
|
|
123
|
+
const toks = tokenize(body, line);
|
|
124
|
+
const p = new Parser(toks, line);
|
|
125
|
+
if (pending.rule) {
|
|
126
|
+
p.eat("|");
|
|
127
|
+
pending.rule.alts.push(p.alternative());
|
|
128
|
+
pending.rule.text += `\n${body.trim()}`;
|
|
129
|
+
} else {
|
|
130
|
+
const rule = p.header(meta, body.trim());
|
|
131
|
+
meta = {};
|
|
132
|
+
rule.alts.push(p.alternative());
|
|
133
|
+
rules.push(rule);
|
|
134
|
+
}
|
|
135
|
+
p.done();
|
|
136
|
+
pending = undefined;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
lines.forEach((raw, i) => {
|
|
140
|
+
const line = i + 1;
|
|
141
|
+
const docMatch = /^\s*##\s*([\w-]+)\s*:\s*(.*)$/.exec(raw);
|
|
142
|
+
if (docMatch) {
|
|
143
|
+
flush();
|
|
144
|
+
const [, key, value] = docMatch;
|
|
145
|
+
meta[key] = meta[key] ? `${meta[key]}\n${value.trim()}` : value.trim();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const code = raw.replace(/(^|\s)#.*$/, "");
|
|
149
|
+
if (!code.trim()) return;
|
|
150
|
+
if (/^\s*\|/.test(code)) {
|
|
151
|
+
flush();
|
|
152
|
+
const rule = rules.at(-1);
|
|
153
|
+
if (!rule) throw new RuleSyntaxError("an alternative needs a rule above it", line);
|
|
154
|
+
pending = { text: code, line, rule };
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (/^\s/.test(code) && pending) {
|
|
158
|
+
pending.text += ` ${code.trim()}`;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
flush();
|
|
162
|
+
pending = { text: code, line };
|
|
163
|
+
});
|
|
164
|
+
flush();
|
|
165
|
+
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
for (const r of rules) {
|
|
168
|
+
if (seen.has(r.name)) throw new RuleSyntaxError(`"${r.name}" is defined twice`, r.line);
|
|
169
|
+
seen.add(r.name);
|
|
170
|
+
}
|
|
171
|
+
return rules;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const LIT = (tok: string): Literal => {
|
|
175
|
+
if (tok === "null") return null;
|
|
176
|
+
if (tok === "true") return true;
|
|
177
|
+
if (tok === "false") return false;
|
|
178
|
+
if (tok.startsWith('"')) return JSON.parse(tok) as string;
|
|
179
|
+
if (/^-?\d/.test(tok)) return Number(tok);
|
|
180
|
+
return tok;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const NAME = /^[A-Za-z_][\w-]*$/;
|
|
184
|
+
|
|
185
|
+
class Parser {
|
|
186
|
+
private i = 0;
|
|
187
|
+
constructor(
|
|
188
|
+
private readonly t: string[],
|
|
189
|
+
private readonly line: number,
|
|
190
|
+
) {}
|
|
191
|
+
|
|
192
|
+
private peek(k = 0): string | undefined {
|
|
193
|
+
return this.t[this.i + k];
|
|
194
|
+
}
|
|
195
|
+
private next(): string {
|
|
196
|
+
const tok = this.t[this.i++];
|
|
197
|
+
if (tok === undefined) throw new RuleSyntaxError("the rule ends too early", this.line);
|
|
198
|
+
return tok;
|
|
199
|
+
}
|
|
200
|
+
eat(x: string): void {
|
|
201
|
+
if (this.t[this.i] !== x) {
|
|
202
|
+
throw new RuleSyntaxError(`expected "${x}" at "${this.t.slice(this.i).join(" ") || "end of rule"}"`, this.line);
|
|
203
|
+
}
|
|
204
|
+
this.i++;
|
|
205
|
+
}
|
|
206
|
+
done(): void {
|
|
207
|
+
if (this.i !== this.t.length) throw new RuleSyntaxError(`cannot read "${this.t.slice(this.i).join(" ")}"`, this.line);
|
|
208
|
+
}
|
|
209
|
+
private name(what: string): string {
|
|
210
|
+
const tok = this.next();
|
|
211
|
+
if (!NAME.test(tok)) throw new RuleSyntaxError(`expected ${what}, found "${tok}"`, this.line);
|
|
212
|
+
return tok;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
header(meta: Record<string, string>, text: string): Rule {
|
|
216
|
+
const name = this.name("a capability name");
|
|
217
|
+
this.eat("(");
|
|
218
|
+
const vars = [this.name("a variable")];
|
|
219
|
+
if (this.peek() === ",") {
|
|
220
|
+
this.next();
|
|
221
|
+
vars.push(this.name("a variable"));
|
|
222
|
+
}
|
|
223
|
+
const params: Param[] = [];
|
|
224
|
+
if (this.peek() === ";") {
|
|
225
|
+
this.next();
|
|
226
|
+
for (;;) {
|
|
227
|
+
const p = this.name("a parameter");
|
|
228
|
+
const optional = this.peek() === "?";
|
|
229
|
+
if (optional) this.next();
|
|
230
|
+
params.push({ name: p, optional });
|
|
231
|
+
if (this.peek() !== ",") break;
|
|
232
|
+
this.next();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.eat(")");
|
|
236
|
+
this.eat(":=");
|
|
237
|
+
return { name, vars, params, alts: [], meta, line: this.line, text };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
alternative(): Alternative {
|
|
241
|
+
const head = this.node();
|
|
242
|
+
const steps: Step[] = [];
|
|
243
|
+
while (this.peek() && /^!?(->|<-)$/.test(this.peek()!)) steps.push(this.step());
|
|
244
|
+
return { head, steps, line: this.line };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private step(): Step {
|
|
248
|
+
const arrow = this.next();
|
|
249
|
+
const negated = arrow.startsWith("!");
|
|
250
|
+
const dir = arrow.endsWith("->") ? "out" : "in";
|
|
251
|
+
let min = 1;
|
|
252
|
+
let max = 1;
|
|
253
|
+
const q = this.peek();
|
|
254
|
+
if (q === "?") {
|
|
255
|
+
this.next();
|
|
256
|
+
min = 0;
|
|
257
|
+
} else if (q === "*") {
|
|
258
|
+
this.next();
|
|
259
|
+
max = Infinity;
|
|
260
|
+
} else if (q === "+") {
|
|
261
|
+
this.next();
|
|
262
|
+
max = Infinity;
|
|
263
|
+
} else if (q && q.startsWith("{")) {
|
|
264
|
+
this.next();
|
|
265
|
+
const m = /^\{(\d*)(,(\d*))?\}$/.exec(q)!;
|
|
266
|
+
min = m[1] === "" ? 0 : Number(m[1]);
|
|
267
|
+
max = m[2] === undefined ? min : m[3] === "" ? Infinity : Number(m[3]);
|
|
268
|
+
if (max < min) throw new RuleSyntaxError(`depth ${q} has its maximum below its minimum`, this.line);
|
|
269
|
+
}
|
|
270
|
+
if (negated && (min !== 1 || max !== 1)) throw new RuleSyntaxError("a negated step takes no depth", this.line);
|
|
271
|
+
const names: Step["names"] = [];
|
|
272
|
+
if (this.peek() === "(") {
|
|
273
|
+
this.next();
|
|
274
|
+
for (;;) {
|
|
275
|
+
names.push(this.edgeName());
|
|
276
|
+
if (this.peek() !== "|") break;
|
|
277
|
+
this.next();
|
|
278
|
+
}
|
|
279
|
+
this.eat(")");
|
|
280
|
+
} else names.push(this.edgeName());
|
|
281
|
+
return { dir, negated, names, min, max, node: this.node() };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private edgeName(): { name: string; args: Value[] } {
|
|
285
|
+
const name = this.name("an edge name");
|
|
286
|
+
// `->grants($action)`: arguments must touch the name, so a following
|
|
287
|
+
// `(a|b)` step is not read as arguments.
|
|
288
|
+
return { name, args: this.peek() === "(" ? this.args() : [] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private args(): Value[] {
|
|
292
|
+
this.eat("(");
|
|
293
|
+
const out: Value[] = [];
|
|
294
|
+
while (this.peek() !== ")") {
|
|
295
|
+
out.push(this.value());
|
|
296
|
+
if (this.peek() === ",") this.next();
|
|
297
|
+
}
|
|
298
|
+
this.eat(")");
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private value(): Value {
|
|
303
|
+
const tok = this.next();
|
|
304
|
+
if (tok.startsWith("$")) return { param: tok.slice(1) };
|
|
305
|
+
return { lit: LIT(tok) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private node(): NodeSpec {
|
|
309
|
+
const spec: NodeSpec = { tests: [] };
|
|
310
|
+
const p = this.peek();
|
|
311
|
+
if (p && NAME.test(p) && /^[a-z_]/.test(p) && this.peek(1) !== "(") {
|
|
312
|
+
spec.v = this.next();
|
|
313
|
+
}
|
|
314
|
+
if (this.peek() === ":") {
|
|
315
|
+
this.next();
|
|
316
|
+
spec.labels = [this.name("a label")];
|
|
317
|
+
while (this.peek() === "|" && /^[A-Z]/.test(this.peek(1) ?? "")) {
|
|
318
|
+
this.next();
|
|
319
|
+
spec.labels.push(this.name("a label"));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (this.peek() === "[") {
|
|
323
|
+
this.next();
|
|
324
|
+
while (this.peek() !== "]") {
|
|
325
|
+
spec.tests.push(this.test());
|
|
326
|
+
if (this.peek() === ",") this.next();
|
|
327
|
+
else if (this.peek() !== "]") throw new RuleSyntaxError(`expected "," or "]" at "${this.peek() ?? "end of rule"}"`, this.line);
|
|
328
|
+
}
|
|
329
|
+
this.eat("]");
|
|
330
|
+
}
|
|
331
|
+
return spec;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private test(): Test {
|
|
335
|
+
let negated = false;
|
|
336
|
+
if (this.peek() === "!") {
|
|
337
|
+
this.next();
|
|
338
|
+
negated = true;
|
|
339
|
+
}
|
|
340
|
+
if (this.peek() === "@") {
|
|
341
|
+
this.next();
|
|
342
|
+
const cap = this.name("a capability name");
|
|
343
|
+
return { kind: "cap", negated, cap, args: this.peek() === "(" ? this.args() : [] };
|
|
344
|
+
}
|
|
345
|
+
const attr = this.name("an attribute");
|
|
346
|
+
const op = this.peek();
|
|
347
|
+
if (op === "," || op === "]") return { kind: "attr", negated, attr, op: "present", values: [] };
|
|
348
|
+
this.next();
|
|
349
|
+
if (op === "in") {
|
|
350
|
+
return { kind: "attr", negated, attr, op: "in", values: this.args() };
|
|
351
|
+
}
|
|
352
|
+
if (op === "=" || op === "!=" || op === "~" || op === "<" || op === "<=" || op === ">" || op === ">=") {
|
|
353
|
+
return { kind: "attr", negated, attr, op, values: [this.value()] };
|
|
354
|
+
}
|
|
355
|
+
throw new RuleSyntaxError(`"${op}" is not a test operator`, this.line);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The capabilities a rule refers to, by name: in `[@cap]` tests and in steps. */
|
|
360
|
+
export function ruleDependencies(rule: Rule): Set<string> {
|
|
361
|
+
const out = new Set<string>();
|
|
362
|
+
const node = (n: NodeSpec): void => {
|
|
363
|
+
for (const t of n.tests) if (t.kind === "cap") out.add(t.cap);
|
|
364
|
+
};
|
|
365
|
+
for (const a of rule.alts) {
|
|
366
|
+
node(a.head);
|
|
367
|
+
for (const s of a.steps) {
|
|
368
|
+
for (const n of s.names) out.add(n.name);
|
|
369
|
+
node(s.node);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|