net/ffi/predicate.rs
1//! C FFI for stateless predicate evaluation (Phase 9c of
2//! `CAPABILITY_SYSTEM_SDK_PLAN.md`).
3//!
4//! Pure helpers — no handles, no state. The substrate's
5//! `Predicate::evaluate_unplanned(ctx)` is mirrored across all
6//! four bindings (Rust SDK, TS, Python, Go) at the SDK layer;
7//! this module brings the same surface to raw C consumers
8//! (C / C++ / Zig / Swift / Java JNI / etc.) so they can
9//! evaluate predicates locally without going through nRPC.
10//!
11//! All inputs cross as NUL-terminated UTF-8 JSON strings:
12//!
13//! - `predicate_json` — wire-format `PredicateWire` JSON.
14//! The same shape every binding emits / accepts; cross-binding
15//! compat is pinned by
16//! `tests/cross_lang_capability/predicate_eval.json`.
17//! - `tags_json` — JSON array of tag strings, e.g.
18//! `["hardware.gpu", "scope:tenant:foo"]`. Reserved-prefix
19//! tags are accepted (parsed via the privileged path).
20//! - `metadata_json` — JSON object of `string -> string`,
21//! e.g. `{"intent": "ml-training", "region": "us-east"}`.
22//!
23//! Cross-binding contract: the same `(predicate, tags, metadata)`
24//! triple produces identical booleans across every binding. Drift
25//! between the C surface and the substrate's evaluator surfaces
26//! as a fixture-driven CI failure on the offending side.
27//!
28//! # Safety
29//!
30//! Every entry point is `unsafe extern "C"` and inherits the
31//! module-wide FFI safety contract (see `ffi/mod.rs` and
32//! `include/net.h`).
33#![allow(clippy::missing_safety_doc)]
34#![expect(
35 clippy::undocumented_unsafe_blocks,
36 reason = "module-wide FFI safety contract documented in the # Safety preamble above"
37)]
38#![expect(
39 clippy::multiple_unsafe_ops_per_block,
40 reason = "FFI entry points deref input pointers together with out-parameter writes under the same caller contract"
41)]
42
43use std::collections::BTreeMap;
44use std::ffi::c_char;
45use std::os::raw::c_int;
46
47use super::NetError;
48use crate::adapter::net::behavior::{
49 predicate_to_rpc_header, EvalContext, PredicateWire, Tag, MAX_PREDICATE_RPC_HEADER_VALUE_LEN,
50 RPC_WHERE_HEADER,
51};
52
53/// Evaluate a wire-format `Predicate` against a `(tags, metadata)`
54/// context. Mirrors `Predicate::evaluate_unplanned(ctx)` from the
55/// substrate.
56///
57/// All three inputs MUST be NUL-terminated UTF-8 JSON strings.
58///
59/// Return values:
60///
61/// - `1` — predicate evaluated to `true`.
62/// - `0` — predicate evaluated to `false`.
63/// - `NetError::NullPointer` (negative) — any of the three
64/// pointers is NULL.
65/// - `NetError::InvalidUtf8` (negative) — input bytes aren't
66/// valid UTF-8.
67/// - `NetError::InvalidJson` (negative) — failed to parse the
68/// `predicate_json` as a `PredicateWire`, the `tags_json` as
69/// a `Vec<String>`, the `metadata_json` as an object, OR
70/// any tag string failed to parse.
71///
72/// Stateless. Thread-safe. The substrate's evaluator visits the
73/// predicate AST in declaration order without planner reordering;
74/// boolean results are invariant under planning, so callers that
75/// want planned evaluation can call this and trust the answer.
76///
77/// # Safety
78///
79/// `predicate_json`, `tags_json`, and `metadata_json` MUST point
80/// at NUL-terminated UTF-8 strings valid for the duration of the
81/// call. The buffers are not retained after return.
82#[unsafe(no_mangle)]
83pub unsafe extern "C" fn net_predicate_evaluate(
84 predicate_json: *const c_char,
85 tags_json: *const c_char,
86 metadata_json: *const c_char,
87) -> c_int {
88 if predicate_json.is_null() || tags_json.is_null() || metadata_json.is_null() {
89 return NetError::NullPointer.into();
90 }
91
92 let pred_s = match unsafe { super::mesh::c_str_to_string(predicate_json) } {
93 Some(s) => s,
94 None => return NetError::InvalidUtf8.into(),
95 };
96 let tags_s = match unsafe { super::mesh::c_str_to_string(tags_json) } {
97 Some(s) => s,
98 None => return NetError::InvalidUtf8.into(),
99 };
100 let meta_s = match unsafe { super::mesh::c_str_to_string(metadata_json) } {
101 Some(s) => s,
102 None => return NetError::InvalidUtf8.into(),
103 };
104
105 let wire: PredicateWire = match serde_json::from_str(&pred_s) {
106 Ok(w) => w,
107 Err(_) => return NetError::InvalidJson.into(),
108 };
109 let predicate = match wire.into_predicate() {
110 Ok(p) => p,
111 Err(_) => return NetError::InvalidJson.into(),
112 };
113
114 let tag_strings: Vec<String> = match serde_json::from_str(&tags_s) {
115 Ok(v) => v,
116 Err(_) => return NetError::InvalidJson.into(),
117 };
118 let tags: Result<Vec<Tag>, _> = tag_strings.iter().map(|s| Tag::parse(s)).collect();
119 let tags = match tags {
120 Ok(t) => t,
121 Err(_) => return NetError::InvalidJson.into(),
122 };
123
124 let metadata: BTreeMap<String, String> = match serde_json::from_str(&meta_s) {
125 Ok(m) => m,
126 Err(_) => return NetError::InvalidJson.into(),
127 };
128
129 let ctx = EvalContext::new(&tags, &metadata);
130 if predicate.evaluate_unplanned(&ctx) {
131 1
132 } else {
133 0
134 }
135}
136
137// =========================================================================
138// Phase 9b — predicate-pushdown header helper
139//
140// Builds the canonical `net-where:` request-header pair for a
141// wire-format predicate. Mirrors the Go SDK's `WhereHeader` helper
142// (`bindings/go/net/capability.go`). The returned `(name, value)`
143// pair drops into any `request_headers`-shaped option list once a
144// header-bearing call variant ships in `libnet_rpc`; today's C
145// ABI in `net_rpc.h` doesn't accept request headers yet, so the
146// helper is documentation + future-proofing.
147//
148// Wire format pinned by
149// `tests/cross_lang_capability/predicate_nrpc_envelope.json`.
150// =========================================================================
151
152/// Encode a wire-format `Predicate` as the canonical
153/// `net-where:` request-header value.
154///
155/// Inputs:
156/// - `predicate_json` — NUL-terminated UTF-8 `PredicateWire`
157/// JSON. The same shape `net_predicate_evaluate` and the
158/// SDK-layer `predicateToWire` produce.
159///
160/// Outputs:
161/// - `*out_header_name` — owned `char*` containing
162/// `"net-where"`. Free via `net_free_string`.
163/// - `*out_header_name_len` — strlen of the header name.
164/// - `*out_value_ptr` — owned `uint8_t*` containing the
165/// canonical JSON bytes. Free via `net_free_string` (the
166/// buffer was allocated as a `CString::into_raw`, same
167/// release path as other string-out helpers in this module).
168/// - `*out_value_len` — byte length of the value buffer.
169///
170/// Returns:
171/// - `0` on success.
172/// - `NetError::NullPointer` (negative) — any pointer NULL.
173/// - `NetError::InvalidUtf8` (negative) — input bytes not UTF-8.
174/// - `NetError::InvalidJson` (negative) — predicate failed to
175/// parse, OR encoded bytes exceed
176/// `MAX_PREDICATE_RPC_HEADER_VALUE_LEN` (4096) per the
177/// substrate's wire-cap rule.
178///
179/// Stateless. Thread-safe.
180///
181/// # Safety
182///
183/// `predicate_json` MUST point at a NUL-terminated UTF-8 string
184/// valid for the duration of the call. Out-pointers must be
185/// writable; on success the caller owns both buffers and frees
186/// them via `net_free_string`.
187#[unsafe(no_mangle)]
188pub unsafe extern "C" fn net_predicate_to_where_header(
189 predicate_json: *const c_char,
190 out_header_name: *mut *mut c_char,
191 out_header_name_len: *mut usize,
192 out_value_ptr: *mut *mut c_char,
193 out_value_len: *mut usize,
194) -> c_int {
195 if predicate_json.is_null()
196 || out_header_name.is_null()
197 || out_header_name_len.is_null()
198 || out_value_ptr.is_null()
199 || out_value_len.is_null()
200 {
201 return NetError::NullPointer.into();
202 }
203
204 let pred_s = match unsafe { super::mesh::c_str_to_string(predicate_json) } {
205 Some(s) => s,
206 None => return NetError::InvalidUtf8.into(),
207 };
208
209 let wire: PredicateWire = match serde_json::from_str(&pred_s) {
210 Ok(w) => w,
211 Err(_) => return NetError::InvalidJson.into(),
212 };
213 let predicate = match wire.into_predicate() {
214 Ok(p) => p,
215 Err(_) => return NetError::InvalidJson.into(),
216 };
217
218 // Encode via the substrate's wire-cap-respecting helper.
219 let (name, value_bytes) = match predicate_to_rpc_header(&predicate) {
220 Ok(pair) => pair,
221 Err(_) => return NetError::InvalidJson.into(),
222 };
223 debug_assert_eq!(name, RPC_WHERE_HEADER);
224 debug_assert!(value_bytes.len() <= MAX_PREDICATE_RPC_HEADER_VALUE_LEN);
225
226 // Write header name out via the existing string helper. The
227 // value bytes are JSON (always UTF-8 since serde_json emits
228 // it that way), so we route through the same `write_string_out`
229 // path — caller frees both via `net_free_string`.
230 let name_rc = super::mesh::write_string_out(name, out_header_name, out_header_name_len);
231 if name_rc != 0 {
232 return name_rc;
233 }
234 // serde_json output is guaranteed valid UTF-8, so the checked
235 // conversion is infallible today. Use the checked variant
236 // anyway so a future refactor that swaps the encoder for a
237 // length-prefixed / postcard / non-text format surfaces a
238 // typed error instead of building a String with invalid UTF-8
239 // (which is unsound to even hold, never mind use). Cost is one
240 // byte-validate pass over a short header value.
241 let value_string = match String::from_utf8(value_bytes) {
242 Ok(s) => s,
243 Err(_) => return NetError::InvalidUtf8.into(),
244 };
245 let value_rc = super::mesh::write_string_out(value_string, out_value_ptr, out_value_len);
246 if value_rc != 0 {
247 // CR-17: defensive cleanup. If the value-side write fails
248 // after the name-side already published a CString into
249 // `*out_header_name`, the caller's contract is "free only
250 // on success" — they won't free it, and the heap leaks.
251 // serde_json output never contains NULs in practice, so
252 // `CString::new` inside the value-side write is unreachable;
253 // this guard is for forward-compat against helper-shape
254 // changes. Recover the orphaned name allocation, NULL the
255 // out-pointer, zero the length, then return the error.
256 unsafe {
257 if !(*out_header_name).is_null() {
258 let _ = std::ffi::CString::from_raw(*out_header_name);
259 *out_header_name = std::ptr::null_mut();
260 *out_header_name_len = 0;
261 }
262 }
263 return value_rc;
264 }
265 0
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::ffi::CString;
272
273 /// Tiny round-trip: a 2-leaf `(exists hardware.gpu) AND
274 /// (metadata_equals region us-east)` predicate should match a
275 /// candidate carrying the gpu tag and the right metadata.
276 #[test]
277 fn evaluates_true_for_matching_context() {
278 let pred = CString::new(
279 r#"{"nodes":[
280 {"kind":"exists","key":{"axis":"hardware","key":"gpu"}},
281 {"kind":"metadata_equals","key":"region","value":"us-east"},
282 {"kind":"and","children":[0,1]}
283 ],"root_idx":2}"#,
284 )
285 .unwrap();
286 let tags = CString::new(r#"["hardware.gpu"]"#).unwrap();
287 let meta = CString::new(r#"{"region":"us-east"}"#).unwrap();
288
289 let rc = unsafe { net_predicate_evaluate(pred.as_ptr(), tags.as_ptr(), meta.as_ptr()) };
290 assert_eq!(rc, 1);
291 }
292
293 #[test]
294 fn evaluates_false_when_metadata_differs() {
295 let pred = CString::new(
296 r#"{"nodes":[
297 {"kind":"metadata_equals","key":"region","value":"us-east"}
298 ],"root_idx":0}"#,
299 )
300 .unwrap();
301 let tags = CString::new(r#"[]"#).unwrap();
302 let meta = CString::new(r#"{"region":"us-west"}"#).unwrap();
303
304 let rc = unsafe { net_predicate_evaluate(pred.as_ptr(), tags.as_ptr(), meta.as_ptr()) };
305 assert_eq!(rc, 0);
306 }
307
308 #[test]
309 fn returns_null_pointer_on_any_null_input() {
310 let pred = CString::new(r#"{"nodes":[],"root_idx":0}"#).unwrap();
311 let tags = CString::new(r#"[]"#).unwrap();
312 let meta = CString::new(r#"{}"#).unwrap();
313
314 let rc = unsafe { net_predicate_evaluate(std::ptr::null(), tags.as_ptr(), meta.as_ptr()) };
315 assert!(rc < 0);
316 let rc = unsafe { net_predicate_evaluate(pred.as_ptr(), std::ptr::null(), meta.as_ptr()) };
317 assert!(rc < 0);
318 let rc = unsafe { net_predicate_evaluate(pred.as_ptr(), tags.as_ptr(), std::ptr::null()) };
319 assert!(rc < 0);
320 }
321
322 #[test]
323 fn returns_invalid_json_on_unparseable_predicate() {
324 let pred = CString::new(r#"{"nodes":[],not-json"#).unwrap();
325 let tags = CString::new(r#"[]"#).unwrap();
326 let meta = CString::new(r#"{}"#).unwrap();
327
328 let rc = unsafe { net_predicate_evaluate(pred.as_ptr(), tags.as_ptr(), meta.as_ptr()) };
329 assert!(rc < 0);
330 }
331
332 /// Reserved-prefix tags (`scope:tenant:foo`) survive the FFI
333 /// tag-array parse — `Tag::parse` (privileged) accepts them
334 /// even though `parse_user` would reject. Predicate keyed on
335 /// the wire-form tag string still evaluates correctly.
336 /// Pin that reserved tags survive the FFI roundtrip so scope-
337 /// driven filters work from C consumers.
338 #[test]
339 fn accepts_reserved_prefix_tags() {
340 // Predicate `MetadataExists("region")` over a context where
341 // metadata is empty AND a reserved-prefix tag is present.
342 // We just need the tags array to parse without erroring;
343 // the predicate evaluation result is incidental.
344 let pred = CString::new(
345 r#"{"nodes":[
346 {"kind":"metadata_exists","key":"region"}
347 ],"root_idx":0}"#,
348 )
349 .unwrap();
350 let tags = CString::new(r#"["scope:tenant:foo","hardware.gpu"]"#).unwrap();
351 let meta = CString::new(r#"{}"#).unwrap();
352
353 let rc = unsafe { net_predicate_evaluate(pred.as_ptr(), tags.as_ptr(), meta.as_ptr()) };
354 // `>= 0` = the tag array parsed cleanly. `< 0` means we
355 // hit `InvalidJson` on the tag parse, which would mean
356 // `Tag::parse` is rejecting the reserved prefix
357 // (regression).
358 assert!(
359 rc >= 0,
360 "reserved-prefix tag must parse via privileged path, got {rc}",
361 );
362 }
363
364 /// `net_predicate_to_where_header` emits the canonical
365 /// `net-where` header name + a JSON-encoded
366 /// `PredicateWire` value. Round-trip the value through
367 /// `serde_json` and assert it decodes to the same predicate.
368 #[test]
369 fn to_where_header_emits_canonical_name_and_round_trip_value() {
370 use std::ffi::CStr;
371
372 let pred = CString::new(
373 r#"{"nodes":[
374 {"kind":"exists","key":{"axis":"hardware","key":"gpu"}}
375 ],"root_idx":0}"#,
376 )
377 .unwrap();
378
379 let mut out_name: *mut c_char = std::ptr::null_mut();
380 let mut name_len: usize = 0;
381 let mut out_value: *mut c_char = std::ptr::null_mut();
382 let mut value_len: usize = 0;
383
384 let rc = unsafe {
385 net_predicate_to_where_header(
386 pred.as_ptr(),
387 &mut out_name,
388 &mut name_len,
389 &mut out_value,
390 &mut value_len,
391 )
392 };
393 assert_eq!(rc, 0);
394
395 // Header name == "net-where".
396 let name = unsafe { CStr::from_ptr(out_name) }
397 .to_str()
398 .unwrap()
399 .to_string();
400 assert_eq!(name, "net-where");
401 assert_eq!(name_len, "net-where".len());
402
403 // Header value parses as PredicateWire and round-trips
404 // back to the same predicate.
405 let value = unsafe { CStr::from_ptr(out_value) }
406 .to_str()
407 .unwrap()
408 .to_string();
409 assert_eq!(value_len, value.len());
410 let parsed: PredicateWire = serde_json::from_str(&value).unwrap();
411 let original: PredicateWire = serde_json::from_str(
412 r#"{"nodes":[{"kind":"exists","key":{"axis":"hardware","key":"gpu"}}],"root_idx":0}"#,
413 )
414 .unwrap();
415 assert_eq!(parsed.nodes.len(), original.nodes.len());
416 assert_eq!(parsed.root_idx, original.root_idx);
417
418 // Free.
419 unsafe {
420 let _ = CString::from_raw(out_name);
421 let _ = CString::from_raw(out_value);
422 }
423 }
424
425 /// `to_where_header` rejects malformed predicate JSON via
426 /// `InvalidJson`.
427 #[test]
428 fn to_where_header_rejects_malformed_predicate() {
429 let pred = CString::new(r#"{"nodes":[],not-json"#).unwrap();
430 let mut out_name: *mut c_char = std::ptr::null_mut();
431 let mut name_len: usize = 0;
432 let mut out_value: *mut c_char = std::ptr::null_mut();
433 let mut value_len: usize = 0;
434
435 let rc = unsafe {
436 net_predicate_to_where_header(
437 pred.as_ptr(),
438 &mut out_name,
439 &mut name_len,
440 &mut out_value,
441 &mut value_len,
442 )
443 };
444 assert!(rc < 0);
445 // Out-pointers should remain NULL since we returned early.
446 assert!(out_name.is_null());
447 assert!(out_value.is_null());
448 }
449
450 /// NULL inputs return `NullPointer`.
451 #[test]
452 fn to_where_header_null_inputs_return_null_pointer() {
453 let pred = CString::new(r#"{"nodes":[],"root_idx":0}"#).unwrap();
454 let mut out_name: *mut c_char = std::ptr::null_mut();
455 let mut name_len: usize = 0;
456 let mut out_value: *mut c_char = std::ptr::null_mut();
457 let mut value_len: usize = 0;
458
459 // predicate NULL
460 let rc = unsafe {
461 net_predicate_to_where_header(
462 std::ptr::null(),
463 &mut out_name,
464 &mut name_len,
465 &mut out_value,
466 &mut value_len,
467 )
468 };
469 assert!(rc < 0);
470
471 // out_name NULL
472 let rc = unsafe {
473 net_predicate_to_where_header(
474 pred.as_ptr(),
475 std::ptr::null_mut(),
476 &mut name_len,
477 &mut out_value,
478 &mut value_len,
479 )
480 };
481 assert!(rc < 0);
482 }
483}