|
| 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 | +} |
0 commit comments