Skip to main content

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}