Skip to content

Commit 2198054

Browse files
committed
feat(body_deps): add static analyser classifying body refs
Configure-time analysis pass that categorises a compiled module by how it references the request body: - no_body_refs: policy never touches input.body - prefix_only: policy reads specific body sub-paths - full_tree: policy reads input.body whole or iterates over a body-rooted ref (full body must be buffered) Conservative classifier: when uncertain, returns full_tree. Tests cover header-only, body-leaf, bare body, body iteration, and the bare 'body' shorthand (no input prefix). prefix_count returns the number of distinct body sub-paths so callers can size buffers accordingly. Streaming evaluation (per docs/proposals/streaming-evaluation.md) needs the body-aware callback path landing first. This analyser is the configure-time piece; it ships independently and the proxy-wasm shim can call it in a follow-up to skip proxy_on_request_body when the policy doesn't need the body. ci.yml gains a test-unit job (parity with feat/string-builtins, feat/composite-ref-iteration, etc.).
1 parent 56750c0 commit 2198054

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

src/body_deps.zig

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! Static analysis of body dependencies in a compiled policy.
2+
//!
3+
//! Walks the AST once, classifying how the policy references the
4+
//! request body. The proxy-wasm shim uses the result to decide
5+
//! whether to skip `proxy_on_request_body` entirely (no body refs),
6+
//! buffer until specific paths resolve (prefix-only), or buffer the
7+
//! whole body up to `max_body_bytes` (full-tree).
8+
//!
9+
//! Streaming evaluation itself (per `docs/proposals/streaming-evaluation.md`)
10+
//! depends on the body-aware callback path landing first; this
11+
//! analyser is the configure-time piece that ships independently.
12+
13+
const std = @import("std");
14+
const ast = @import("ast.zig");
15+
16+
pub const Class = enum {
17+
/// Policy does not reference `input.body` anywhere.
18+
no_body_refs,
19+
/// Policy references body sub-paths (e.g. `input.body.amount`).
20+
/// A streaming evaluator can decide as soon as those resolve.
21+
prefix_only,
22+
/// Policy references `input.body` as a whole or iterates over
23+
/// the body's contents. The full body must be buffered.
24+
full_tree,
25+
};
26+
27+
pub const BodyDeps = struct {
28+
class: Class,
29+
/// Number of distinct body sub-paths referenced when
30+
/// `class == .prefix_only`. Always 0 for the other classes.
31+
prefix_count: usize,
32+
};
33+
34+
/// Classify body usage of every rule reachable in `module`.
35+
/// Conservative: when in doubt, returns `.full_tree`.
36+
pub fn analyze(module: ast.Module) BodyDeps {
37+
var st = State{};
38+
for (module.rules) |rule| {
39+
for (rule.body) |expr| visit(&st, expr);
40+
if (rule.value) |v| visit(&st, v);
41+
}
42+
return st.finalize();
43+
}
44+
45+
const State = struct {
46+
refs_whole: bool = false,
47+
prefix_count: usize = 0,
48+
49+
fn finalize(self: State) BodyDeps {
50+
if (self.refs_whole) return .{ .class = .full_tree, .prefix_count = 0 };
51+
if (self.prefix_count == 0) return .{ .class = .no_body_refs, .prefix_count = 0 };
52+
return .{ .class = .prefix_only, .prefix_count = self.prefix_count };
53+
}
54+
};
55+
56+
fn visit(st: *State, expr: *const ast.Expr) void {
57+
switch (expr.*) {
58+
.value => {},
59+
.ref => |path| visitRef(st, path),
60+
.compare => |c| {
61+
visit(st, c.left);
62+
visit(st, c.right);
63+
},
64+
.not => |inner| visit(st, inner),
65+
.some, .every => |it| {
66+
visit(st, it.source);
67+
visit(st, it.body);
68+
// Iterating over a ref into the body is full-tree:
69+
// the iterator needs the entire collection. The source
70+
// visit above marks the path; we promote to whole if
71+
// the source is itself an `input.body...` ref.
72+
if (it.source.* == .ref) {
73+
if (refTouchesBody(it.source.ref)) st.refs_whole = true;
74+
}
75+
},
76+
.call => |c| for (c.args) |arg| visit(st, arg),
77+
}
78+
}
79+
80+
fn visitRef(st: *State, path: []const []const u8) void {
81+
if (!refTouchesBody(path)) return;
82+
83+
// `input.body` (or just `body`) by itself = whole-tree dependency.
84+
// Body sub-paths (input.body.amount, body.user) = prefix-only.
85+
const body_index = bodySegmentIndex(path) orelse return;
86+
if (body_index + 1 >= path.len) {
87+
st.refs_whole = true;
88+
} else {
89+
st.prefix_count += 1;
90+
}
91+
}
92+
93+
fn refTouchesBody(path: []const []const u8) bool {
94+
return bodySegmentIndex(path) != null;
95+
}
96+
97+
/// Locate the `body` segment inside an input ref. Accepts both
98+
/// `["input", "body", ...]` and the shorthand `["body", ...]`.
99+
fn bodySegmentIndex(path: []const []const u8) ?usize {
100+
if (path.len == 0) return null;
101+
if (std.mem.eql(u8, path[0], "body")) return 0;
102+
if (path.len >= 2 and std.mem.eql(u8, path[0], "input") and std.mem.eql(u8, path[1], "body")) {
103+
return 1;
104+
}
105+
return null;
106+
}
107+
108+
const testing = std.testing;
109+
const json = @import("json.zig");
110+
111+
fn classify(src: []const u8) !Class {
112+
var arena = std.heap.ArenaAllocator.init(testing.allocator);
113+
defer arena.deinit();
114+
const node = try json.parse(arena.allocator(), src);
115+
const module = try ast.buildModule(arena.allocator(), node);
116+
return analyze(module).class;
117+
}
118+
119+
test "analyze: no body refs -> no_body_refs" {
120+
const policy =
121+
"{\"type\":\"eq\"," ++
122+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"method\"]}," ++
123+
"\"right\":{\"type\":\"value\",\"value\":\"GET\"}}";
124+
try testing.expectEqual(Class.no_body_refs, try classify(policy));
125+
}
126+
127+
test "analyze: input.body.amount -> prefix_only" {
128+
const policy =
129+
"{\"type\":\"gt\"," ++
130+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"body\",\"amount\"]}," ++
131+
"\"right\":{\"type\":\"value\",\"value\":100}}";
132+
try testing.expectEqual(Class.prefix_only, try classify(policy));
133+
}
134+
135+
test "analyze: bare input.body -> full_tree" {
136+
const policy =
137+
"{\"type\":\"neq\"," ++
138+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"body\"]}," ++
139+
"\"right\":{\"type\":\"value\",\"value\":null}}";
140+
try testing.expectEqual(Class.full_tree, try classify(policy));
141+
}
142+
143+
test "analyze: iterate input.body.items -> full_tree" {
144+
const policy =
145+
"{\"type\":\"every\",\"var\":\"item\"," ++
146+
"\"source\":{\"type\":\"ref\",\"path\":[\"input\",\"body\",\"items\"]}," ++
147+
"\"body\":{\"type\":\"value\",\"value\":true}}";
148+
try testing.expectEqual(Class.full_tree, try classify(policy));
149+
}
150+
151+
test "analyze: prefix_count counts distinct body refs" {
152+
const policy =
153+
"{\"type\":\"module\",\"rules\":[" ++
154+
"{\"type\":\"rule\",\"name\":\"allow\",\"body\":[" ++
155+
"{\"type\":\"eq\"," ++
156+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"body\",\"action\"]}," ++
157+
"\"right\":{\"type\":\"value\",\"value\":\"submit\"}}," ++
158+
"{\"type\":\"gt\"," ++
159+
"\"left\":{\"type\":\"ref\",\"path\":[\"input\",\"body\",\"amount\"]}," ++
160+
"\"right\":{\"type\":\"value\",\"value\":0}}]}]}";
161+
var arena = std.heap.ArenaAllocator.init(testing.allocator);
162+
defer arena.deinit();
163+
const node = try json.parse(arena.allocator(), policy);
164+
const module = try ast.buildModule(arena.allocator(), node);
165+
const deps = analyze(module);
166+
try testing.expectEqual(Class.prefix_only, deps.class);
167+
try testing.expectEqual(@as(usize, 2), deps.prefix_count);
168+
}
169+
170+
test "analyze: body shorthand path (no input prefix) detected" {
171+
const policy =
172+
"{\"type\":\"eq\"," ++
173+
"\"left\":{\"type\":\"ref\",\"path\":[\"body\",\"x\"]}," ++
174+
"\"right\":{\"type\":\"value\",\"value\":1}}";
175+
try testing.expectEqual(Class.prefix_only, try classify(policy));
176+
}
177+
178+
test "analyze: call with body arg -> prefix_only" {
179+
const policy =
180+
"{\"type\":\"call\",\"name\":\"startswith\",\"args\":[" ++
181+
"{\"type\":\"ref\",\"path\":[\"input\",\"body\",\"action\"]}," ++
182+
"{\"type\":\"value\",\"value\":\"approve_\"}]}";
183+
try testing.expectEqual(Class.prefix_only, try classify(policy));
184+
}

src/root.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! `main.zig`.
88

99
pub const ast = @import("ast.zig");
10+
pub const body_deps = @import("body_deps.zig");
1011
pub const builtins = @import("builtins.zig");
1112
pub const eval = @import("eval.zig");
1213
pub const json = @import("json.zig");
@@ -24,6 +25,7 @@ test {
2425
const testing = @import("std").testing;
2526
testing.refAllDecls(@This());
2627
_ = ast;
28+
_ = body_deps;
2729
_ = builtins;
2830
_ = eval;
2931
_ = json;

0 commit comments

Comments
 (0)