Skip to main content

net/ffi/
predicate_debug.rs

1//! C FFI for predicate debug-session helpers (Phase 9d of
2//! `CAPABILITY_SYSTEM_SDK_PLAN.md`).
3//!
4//! Three pure helpers — no handles, no state — exposing what
5//! every other binding ships at the SDK layer:
6//!
7//!   - `net_predicate_evaluate_with_trace` — single-evaluation
8//!     trace tree (per-clause `label` / `result` / `children`).
9//!   - `net_predicate_aggregate_debug_report` — corpus-wide
10//!     aggregator: total / matched / per-clause `(evaluated,
11//!     matched)` rollup keyed by debug label.
12//!   - `net_predicate_redact_metadata_keys` — host-side scrubber
13//!     that rewrites metadata-clause labels before persistence.
14//!     The substrate doesn't ship a redaction implementation
15//!     (Phase 6 of `CAPABILITY_ENHANCEMENTS_PLAN.md` defined the
16//!     API but only the trace + aggregator landed); each binding
17//!     implements it. This module ports the same logic the TS /
18//!     Python / Go SDKs ship.
19//!
20//! Cross-binding contracts pinned by:
21//!
22//!   - `tests/cross_lang_capability/predicate_trace.json`
23//!   - `tests/cross_lang_capability/predicate_debug_report.json`
24//!   - `tests/cross_lang_capability/predicate_debug_report_redacted.json`
25//!
26//! Wire shapes mirror the test renderers in
27//! `tests/cross_lang_capability_fixtures.rs`.
28//!
29//! # Safety
30//!
31//! Every entry point is `unsafe extern "C"` and inherits the
32//! module-wide FFI safety contract (see `ffi/mod.rs` and
33//! `include/net.h`): NUL-terminated UTF-8 JSON inputs, valid
34//! out-parameter pointers, caller-frees-Rust-allocated-strings.
35#![allow(clippy::missing_safety_doc)]
36#![expect(
37    clippy::undocumented_unsafe_blocks,
38    reason = "module-wide FFI safety contract documented in the # Safety preamble above"
39)]
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::ffi::c_char;
43use std::os::raw::c_int;
44
45use serde_json::{json, Value};
46
47use super::NetError;
48use crate::adapter::net::behavior::{
49    ClauseTrace, EvalContext, PredicateDebugReport, PredicateWire, Tag,
50};
51
52// =========================================================================
53// Wire-format renderers — mirror the cross-binding fixture canonical
54// shape. Duplicate of the test renderer; if both diverge, the
55// fixture-comparison tests trip on the offending side.
56// =========================================================================
57
58fn clause_trace_to_wire(t: &ClauseTrace) -> Value {
59    json!({
60        "label": t.label,
61        "result": t.result,
62        "children": t.children.iter().map(clause_trace_to_wire).collect::<Vec<_>>(),
63    })
64}
65
66/// Render a `PredicateDebugReport` to its canonical wire shape.
67/// `clause_stats` becomes a label-sorted array (matches the
68/// `BTreeMap` iteration order on the substrate side).
69fn report_to_wire(report: &PredicateDebugReport) -> Value {
70    let stats: Vec<Value> = report
71        .clause_stats
72        .values()
73        .map(|s| {
74            json!({
75                "label": s.label,
76                "evaluated": s.evaluated,
77                "matched": s.matched,
78            })
79        })
80        .collect();
81    json!({
82        "total_candidates": report.total_candidates,
83        "matched": report.matched,
84        "clause_stats": stats,
85    })
86}
87
88// =========================================================================
89// Helpers shared with `ffi::predicate` — keeping them private here so
90// the slice stays self-contained. Both modules go through `c_str_to_string`
91// + `write_string_out` from `super::mesh`.
92// =========================================================================
93
94/// Parse a JSON `Vec<String>` of tag wire-form strings into typed
95/// `Tag`s via the privileged path (so reserved-prefix tags
96/// survive). Returns the parsed vector or `None` on any parse
97/// failure.
98fn parse_tag_array(tags_json_str: &str) -> Option<Vec<Tag>> {
99    let strings: Vec<String> = serde_json::from_str(tags_json_str).ok()?;
100    strings
101        .iter()
102        .map(|s| Tag::parse(s))
103        .collect::<Result<_, _>>()
104        .ok()
105}
106
107/// Parse a `BTreeMap<String, String>` from JSON.
108fn parse_metadata(metadata_json_str: &str) -> Option<BTreeMap<String, String>> {
109    serde_json::from_str(metadata_json_str).ok()
110}
111
112// =========================================================================
113// Phase 9d — evaluate_with_trace
114// =========================================================================
115
116/// Evaluate a wire-format `Predicate` against `(tags, metadata)`
117/// and write a [`ClauseTrace`] tree to the out-param.
118///
119/// Mirrors `Predicate::evaluate_with_trace(ctx)`. The trace
120/// preserves the planner's short-circuit behavior: descendants
121/// that didn't run are absent from the tree.
122///
123/// Inputs (NUL-terminated UTF-8 JSON):
124///
125///   - `predicate_json` — wire-format `PredicateWire`.
126///   - `tags_json`      — JSON array of tag strings.
127///   - `metadata_json`  — JSON object of `string -> string`.
128///
129/// Outputs:
130///
131///   - `out_result` — set to `1` if the predicate matched, `0`
132///     otherwise.
133///   - `out_trace_json` / `out_trace_len` — the trace tree's
134///     JSON. Free with `net_free_string`. Wire shape:
135///     `{"label": str, "result": bool, "children": [...]}`
136///     recursively.
137///
138/// Returns `0` on success, `NetError::*` (negative) on failure.
139///
140/// # Safety
141///
142/// All input pointers MUST point at NUL-terminated UTF-8 strings
143/// valid for the duration of the call. `out_*` pointers must be
144/// writable; on success the caller owns the trace buffer and
145/// frees it via `net_free_string`.
146#[unsafe(no_mangle)]
147pub unsafe extern "C" fn net_predicate_evaluate_with_trace(
148    predicate_json: *const c_char,
149    tags_json: *const c_char,
150    metadata_json: *const c_char,
151    out_result: *mut c_int,
152    out_trace_json: *mut *mut c_char,
153    out_trace_len: *mut usize,
154) -> c_int {
155    if predicate_json.is_null()
156        || tags_json.is_null()
157        || metadata_json.is_null()
158        || out_result.is_null()
159        || out_trace_json.is_null()
160        || out_trace_len.is_null()
161    {
162        return NetError::NullPointer.into();
163    }
164
165    let pred_s = match unsafe { super::mesh::c_str_to_string(predicate_json) } {
166        Some(s) => s,
167        None => return NetError::InvalidUtf8.into(),
168    };
169    let tags_s = match unsafe { super::mesh::c_str_to_string(tags_json) } {
170        Some(s) => s,
171        None => return NetError::InvalidUtf8.into(),
172    };
173    let meta_s = match unsafe { super::mesh::c_str_to_string(metadata_json) } {
174        Some(s) => s,
175        None => return NetError::InvalidUtf8.into(),
176    };
177
178    let wire: PredicateWire = match serde_json::from_str(&pred_s) {
179        Ok(w) => w,
180        Err(_) => return NetError::InvalidJson.into(),
181    };
182    let predicate = match wire.into_predicate() {
183        Ok(p) => p,
184        Err(_) => return NetError::InvalidJson.into(),
185    };
186    let Some(tags) = parse_tag_array(&tags_s) else {
187        return NetError::InvalidJson.into();
188    };
189    let Some(metadata) = parse_metadata(&meta_s) else {
190        return NetError::InvalidJson.into();
191    };
192
193    let ctx = EvalContext::new(&tags, &metadata);
194    let (result, trace) = predicate.evaluate_with_trace(&ctx);
195
196    unsafe {
197        *out_result = if result { 1 } else { 0 };
198    }
199    let payload = clause_trace_to_wire(&trace);
200    super::mesh::write_string_out(
201        serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()),
202        out_trace_json,
203        out_trace_len,
204    )
205}
206
207// =========================================================================
208// Phase 9d — aggregate_debug_report
209// =========================================================================
210
211/// Run `predicate` against every entry in `contexts_json` and
212/// write a [`PredicateDebugReport`] to the out-param. Mirrors
213/// `PredicateDebugReport::from_evaluations(pred, contexts)`.
214///
215/// Inputs (NUL-terminated UTF-8 JSON):
216///
217///   - `predicate_json` — wire-format `PredicateWire`.
218///   - `contexts_json`  — JSON array of evaluation contexts:
219///     `[{"tags": [...], "metadata": {...}}, ...]`. Each context
220///     contributes one corpus row.
221///
222/// Outputs:
223///
224///   - `out_report_json` / `out_report_len` — the report JSON.
225///     Free with `net_free_string`. Wire shape:
226///
227/// ```json
228/// {
229///   "total_candidates": <usize>,
230///   "matched": <usize>,
231///   "clause_stats": [
232///     {"label": "<debug-label>", "evaluated": <usize>, "matched": <usize>},
233///     ...
234///   ]
235/// }
236/// ```
237///
238/// `clause_stats` is sorted by label (the substrate uses
239/// `BTreeMap`, so iteration is in label order).
240///
241/// Returns `0` on success, `NetError::*` (negative) on parse /
242/// null-pointer failure.
243///
244/// # Safety
245///
246/// All input pointers MUST point at NUL-terminated UTF-8 strings.
247/// On success the caller owns the report buffer and frees it via
248/// `net_free_string`.
249#[unsafe(no_mangle)]
250pub unsafe extern "C" fn net_predicate_aggregate_debug_report(
251    predicate_json: *const c_char,
252    contexts_json: *const c_char,
253    out_report_json: *mut *mut c_char,
254    out_report_len: *mut usize,
255) -> c_int {
256    if predicate_json.is_null()
257        || contexts_json.is_null()
258        || out_report_json.is_null()
259        || out_report_len.is_null()
260    {
261        return NetError::NullPointer.into();
262    }
263
264    let pred_s = match unsafe { super::mesh::c_str_to_string(predicate_json) } {
265        Some(s) => s,
266        None => return NetError::InvalidUtf8.into(),
267    };
268    let ctx_s = match unsafe { super::mesh::c_str_to_string(contexts_json) } {
269        Some(s) => s,
270        None => return NetError::InvalidUtf8.into(),
271    };
272
273    let wire: PredicateWire = match serde_json::from_str(&pred_s) {
274        Ok(w) => w,
275        Err(_) => return NetError::InvalidJson.into(),
276    };
277    let predicate = match wire.into_predicate() {
278        Ok(p) => p,
279        Err(_) => return NetError::InvalidJson.into(),
280    };
281
282    // Decode the corpus into owned `(Vec<Tag>, BTreeMap)` pairs
283    // so each `EvalContext` can borrow them. `EvalContext::new`
284    // takes a `&[Tag]` slice; the owning Vec must outlive the
285    // iteration. Same shape the test renderer uses.
286    #[derive(serde::Deserialize)]
287    struct CtxJson {
288        tags: Vec<String>,
289        metadata: BTreeMap<String, String>,
290    }
291    let raw_contexts: Vec<CtxJson> = match serde_json::from_str(&ctx_s) {
292        Ok(v) => v,
293        Err(_) => return NetError::InvalidJson.into(),
294    };
295    let mut owned: Vec<(Vec<Tag>, BTreeMap<String, String>)> =
296        Vec::with_capacity(raw_contexts.len());
297    for c in raw_contexts {
298        let tags: Result<Vec<Tag>, _> = c.tags.iter().map(|s| Tag::parse(s)).collect();
299        let Ok(tags) = tags else {
300            return NetError::InvalidJson.into();
301        };
302        owned.push((tags, c.metadata));
303    }
304
305    let report = PredicateDebugReport::from_evaluations(
306        &predicate,
307        owned
308            .iter()
309            .map(|(tags, meta)| EvalContext::new(tags, meta)),
310    );
311
312    let payload = report_to_wire(&report);
313    super::mesh::write_string_out(
314        serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()),
315        out_report_json,
316        out_report_len,
317    )
318}
319
320// =========================================================================
321// Phase 9d — redact_metadata_keys
322//
323// Pure host-side label rewriter. The substrate doesn't ship a
324// redaction impl; each binding implements it. This module ports
325// the logic from sdk-py / sdk-ts / Go SDK so raw C consumers get
326// parity.
327//
328// Redaction rules (only metadata-clause labels carrying values
329// are rewritten; everything else passes through):
330//
331//   MetadataEquals(<key>=<value>)            → MetadataEquals(<key>=<redacted>)
332//   MetadataMatches(<key> contains "<pat>")  → MetadataMatches(<key> contains "<redacted>")
333//   MetadataNumericAtLeast(<key> >= <thr>)   → MetadataNumericAtLeast(<key> >= <redacted>)
334//   MetadataExists(<key>)                    — unchanged (no value)
335//   non-metadata labels                      — unchanged
336//
337// After rewriting, stats with the same redacted label are merged
338// (`evaluated` and `matched` summed). Output is sorted by label.
339// Idempotent: redact(redact(r, k), k) == redact(r, k).
340// =========================================================================
341
342/// Strip a prefix and suffix from a label, returning the inside
343/// or `None` if either anchor doesn't match.
344fn strip_label<'a>(label: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
345    label
346        .strip_prefix(prefix)
347        .and_then(|rest| rest.strip_suffix(suffix))
348}
349
350/// Try each occurrence of `separator` in `inner` from earliest to
351/// latest. Return the first split position whose left half is in
352/// `keys`. CR-19: a metadata key may legitimately contain `=`,
353/// ` contains "`, or ` >= ` (substrate's `BTreeMap<String,String>`
354/// metadata accepts arbitrary keys), so the previous "split at
355/// first separator" heuristic silently no-op'd redaction when the
356/// key embedded the separator. Try every split position and keep
357/// the first one that resolves to a redact-set key.
358fn find_redactable_key_split(
359    inner: &str,
360    separator: &str,
361    keys: &BTreeSet<String>,
362) -> Option<usize> {
363    let mut search_start = 0usize;
364    while let Some(rel) = inner[search_start..].find(separator) {
365        let abs = search_start + rel;
366        if keys.contains(&inner[..abs]) {
367            return Some(abs);
368        }
369        search_start = abs + separator.len();
370        if search_start > inner.len() {
371            break;
372        }
373    }
374    None
375}
376
377/// Redact a single label per the rules above. Returns the
378/// rewritten label (owned `String`); falls through for non-
379/// metadata or non-targeted-key labels.
380fn redact_label(label: &str, keys: &BTreeSet<String>) -> String {
381    // MetadataEquals(<key>=<value>)
382    if let Some(inner) = strip_label(label, "MetadataEquals(", ")") {
383        if let Some(eq_idx) = find_redactable_key_split(inner, "=", keys) {
384            let key = &inner[..eq_idx];
385            return format!("MetadataEquals({key}=<redacted>)");
386        }
387        return label.to_string();
388    }
389    // MetadataMatches(<key> contains "<pattern>")
390    if let Some(inner) = strip_label(label, "MetadataMatches(", ")") {
391        let needle = " contains \"";
392        if let Some(at) = find_redactable_key_split(inner, needle, keys) {
393            // `inner` ends with `"` (closing of the pattern literal).
394            if inner.ends_with('"') {
395                let key = &inner[..at];
396                return format!("MetadataMatches({key} contains \"<redacted>\")");
397            }
398        }
399        return label.to_string();
400    }
401    // MetadataNumericAtLeast(<key> >= <threshold>)
402    if let Some(inner) = strip_label(label, "MetadataNumericAtLeast(", ")") {
403        let needle = " >= ";
404        if let Some(at) = find_redactable_key_split(inner, needle, keys) {
405            let key = &inner[..at];
406            return format!("MetadataNumericAtLeast({key} >= <redacted>)");
407        }
408        return label.to_string();
409    }
410    // Anything else passes through (`MetadataExists`, all non-
411    // metadata leaves, composites).
412    label.to_string()
413}
414
415/// Apply the `redact_label` rewrite (private helper above)
416/// across a wire-format report and write the redacted report
417/// to the out-param.
418///
419/// Inputs (NUL-terminated UTF-8 JSON):
420///
421///   - `report_json` — wire-format `PredicateDebugReport`
422///     (output of [`net_predicate_aggregate_debug_report`]).
423///   - `keys_json`   — JSON array of metadata key names whose
424///     values should be scrubbed:
425///     `["api_key", "secret_token"]`.
426///
427/// Outputs:
428///
429///   - `out_redacted_json` / `out_redacted_len` — the redacted
430///     report JSON. Free with `net_free_string`. Same wire shape
431///     as the input report; `clause_stats` re-sorted by label
432///     after redaction (since redacted labels may collide and
433///     merge).
434///
435/// Returns `0` on success, `NetError::*` (negative) on parse /
436/// null-pointer failure.
437///
438/// Idempotent: redacting an already-redacted report with the
439/// same keys is a no-op.
440///
441/// # Safety
442///
443/// All input pointers MUST point at NUL-terminated UTF-8 strings.
444/// On success the caller owns the redacted-report buffer and
445/// frees it via `net_free_string`.
446#[unsafe(no_mangle)]
447pub unsafe extern "C" fn net_predicate_redact_metadata_keys(
448    report_json: *const c_char,
449    keys_json: *const c_char,
450    out_redacted_json: *mut *mut c_char,
451    out_redacted_len: *mut usize,
452) -> c_int {
453    if report_json.is_null()
454        || keys_json.is_null()
455        || out_redacted_json.is_null()
456        || out_redacted_len.is_null()
457    {
458        return NetError::NullPointer.into();
459    }
460
461    let report_s = match unsafe { super::mesh::c_str_to_string(report_json) } {
462        Some(s) => s,
463        None => return NetError::InvalidUtf8.into(),
464    };
465    let keys_s = match unsafe { super::mesh::c_str_to_string(keys_json) } {
466        Some(s) => s,
467        None => return NetError::InvalidUtf8.into(),
468    };
469
470    let report: Value = match serde_json::from_str(&report_s) {
471        Ok(v) => v,
472        Err(_) => return NetError::InvalidJson.into(),
473    };
474    let keys_vec: Vec<String> = match serde_json::from_str(&keys_s) {
475        Ok(v) => v,
476        Err(_) => return NetError::InvalidJson.into(),
477    };
478    let keys: BTreeSet<String> = keys_vec.into_iter().collect();
479
480    // Walk `clause_stats`, redact each label, merge collisions.
481    let stats = match report.get("clause_stats").and_then(|s| s.as_array()) {
482        Some(s) => s,
483        None => return NetError::InvalidJson.into(),
484    };
485    let mut merged: BTreeMap<String, (u64, u64)> = BTreeMap::new();
486    for entry in stats {
487        let label = match entry.get("label").and_then(|l| l.as_str()) {
488            Some(l) => l.to_string(),
489            None => return NetError::InvalidJson.into(),
490        };
491        let evaluated = entry.get("evaluated").and_then(|n| n.as_u64()).unwrap_or(0);
492        let matched = entry.get("matched").and_then(|n| n.as_u64()).unwrap_or(0);
493        let new_label = redact_label(&label, &keys);
494        let slot = merged.entry(new_label).or_insert((0, 0));
495        slot.0 += evaluated;
496        slot.1 += matched;
497    }
498    let new_stats: Vec<Value> = merged
499        .into_iter()
500        .map(|(label, (evaluated, matched))| {
501            json!({
502                "label": label,
503                "evaluated": evaluated,
504                "matched": matched,
505            })
506        })
507        .collect();
508
509    // Preserve the top-level counters from the input report.
510    let total = report
511        .get("total_candidates")
512        .and_then(|n| n.as_u64())
513        .unwrap_or(0);
514    let matched = report.get("matched").and_then(|n| n.as_u64()).unwrap_or(0);
515
516    let payload = json!({
517        "total_candidates": total,
518        "matched": matched,
519        "clause_stats": new_stats,
520    });
521    super::mesh::write_string_out(
522        serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()),
523        out_redacted_json,
524        out_redacted_len,
525    )
526}
527
528// =========================================================================
529// CR-8 — redact_trace_metadata_keys
530//
531// `redact_metadata_keys` (above) only scrubs the `clause_stats`
532// of an aggregated `PredicateDebugReport`. The single-eval trace
533// tree produced by `net_predicate_evaluate_with_trace` carries the
534// same kind of metadata-clause labels (`MetadataEquals(api_key=
535// sk-...)`), and consumers persisting traces for offline analysis
536// have no way to scrub them today. This entry point applies the
537// same `redact_label` rewrite recursively across the trace.
538// =========================================================================
539
540/// Walk a trace-tree `Value` and redact every `label` that matches
541/// the metadata-clause shapes. Children are rewritten in place; the
542/// `result` field is preserved.
543fn redact_trace_value(node: &Value, keys: &BTreeSet<String>) -> Value {
544    let label = node
545        .get("label")
546        .and_then(|l| l.as_str())
547        .unwrap_or_default();
548    let result = node.get("result").cloned().unwrap_or(Value::Null);
549    let children: Vec<Value> = node
550        .get("children")
551        .and_then(|c| c.as_array())
552        .map(|arr| arr.iter().map(|c| redact_trace_value(c, keys)).collect())
553        .unwrap_or_default();
554    json!({
555        "label": redact_label(label, keys),
556        "result": result,
557        "children": children,
558    })
559}
560
561/// Apply the `redact_label` rewrite across a wire-format trace
562/// tree (the JSON output of [`net_predicate_evaluate_with_trace`]).
563///
564/// Inputs (NUL-terminated UTF-8 JSON):
565///
566///   - `trace_json` — wire-format `ClauseTrace` shape
567///     (`{"label", "result", "children": [...]}` recursively).
568///   - `keys_json`  — JSON array of metadata key names whose
569///     values should be scrubbed: `["api_key", "secret_token"]`.
570///
571/// Outputs:
572///
573///   - `out_redacted_json` / `out_redacted_len` — the redacted
574///     trace JSON. Free with `net_free_string`. Same wire shape as
575///     the input. Children order is preserved.
576///
577/// Returns `0` on success, `NetError::*` (negative) on parse /
578/// null-pointer failure.
579///
580/// Idempotent: redacting an already-redacted trace with the same
581/// keys is a no-op.
582///
583/// # Safety
584///
585/// All input pointers MUST point at NUL-terminated UTF-8 strings.
586/// On success the caller owns the redacted-trace buffer and frees
587/// it via `net_free_string`.
588#[unsafe(no_mangle)]
589pub unsafe extern "C" fn net_predicate_redact_trace_metadata_keys(
590    trace_json: *const c_char,
591    keys_json: *const c_char,
592    out_redacted_json: *mut *mut c_char,
593    out_redacted_len: *mut usize,
594) -> c_int {
595    if trace_json.is_null()
596        || keys_json.is_null()
597        || out_redacted_json.is_null()
598        || out_redacted_len.is_null()
599    {
600        return NetError::NullPointer.into();
601    }
602
603    let trace_s = match unsafe { super::mesh::c_str_to_string(trace_json) } {
604        Some(s) => s,
605        None => return NetError::InvalidUtf8.into(),
606    };
607    let keys_s = match unsafe { super::mesh::c_str_to_string(keys_json) } {
608        Some(s) => s,
609        None => return NetError::InvalidUtf8.into(),
610    };
611
612    let trace: Value = match serde_json::from_str(&trace_s) {
613        Ok(v) => v,
614        Err(_) => return NetError::InvalidJson.into(),
615    };
616    let keys_vec: Vec<String> = match serde_json::from_str(&keys_s) {
617        Ok(v) => v,
618        Err(_) => return NetError::InvalidJson.into(),
619    };
620    let keys: BTreeSet<String> = keys_vec.into_iter().collect();
621
622    let redacted = redact_trace_value(&trace, &keys);
623    super::mesh::write_string_out(
624        serde_json::to_string(&redacted).unwrap_or_else(|_| "{}".to_string()),
625        out_redacted_json,
626        out_redacted_len,
627    )
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use std::ffi::{CStr, CString};
634
635    /// Helper: read a CString out-param, free it, return owned String.
636    fn read_and_free(ptr: *mut c_char) -> String {
637        assert!(!ptr.is_null());
638        let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string();
639        unsafe {
640            let _ = CString::from_raw(ptr);
641        }
642        s
643    }
644
645    /// `evaluate_with_trace` for a 2-leaf AND. The matching path
646    /// should produce a trace tree with the And label and both
647    /// children's results.
648    #[test]
649    fn evaluate_with_trace_records_full_tree() {
650        let pred = CString::new(
651            r#"{"nodes":[
652                {"kind":"exists","key":{"axis":"hardware","key":"gpu"}},
653                {"kind":"metadata_equals","key":"region","value":"us-east"},
654                {"kind":"and","children":[0,1]}
655            ],"root_idx":2}"#,
656        )
657        .unwrap();
658        let tags = CString::new(r#"["hardware.gpu"]"#).unwrap();
659        let meta = CString::new(r#"{"region":"us-east"}"#).unwrap();
660
661        let mut result: c_int = -1;
662        let mut out_ptr: *mut c_char = std::ptr::null_mut();
663        let mut out_len: usize = 0;
664        let rc = unsafe {
665            net_predicate_evaluate_with_trace(
666                pred.as_ptr(),
667                tags.as_ptr(),
668                meta.as_ptr(),
669                &mut result,
670                &mut out_ptr,
671                &mut out_len,
672            )
673        };
674        assert_eq!(rc, 0);
675        assert_eq!(result, 1);
676
677        let trace_json = read_and_free(out_ptr);
678        let v: Value = serde_json::from_str(&trace_json).unwrap();
679        assert!(v["label"].as_str().unwrap().starts_with("And"));
680        assert_eq!(v["result"], true);
681        let children = v["children"].as_array().unwrap();
682        assert_eq!(children.len(), 2);
683        // Both leaves matched.
684        assert!(children.iter().all(|c| c["result"] == true));
685    }
686
687    /// `aggregate_debug_report` over a 3-row corpus. Should
688    /// produce `total=3`, `matched` = how many matched, and
689    /// per-clause stats.
690    #[test]
691    fn aggregate_debug_report_rolls_up_per_clause_stats() {
692        let pred = CString::new(
693            r#"{"nodes":[
694                {"kind":"metadata_equals","key":"region","value":"us-east"}
695            ],"root_idx":0}"#,
696        )
697        .unwrap();
698        let contexts = CString::new(
699            r#"[
700                {"tags":[],"metadata":{"region":"us-east"}},
701                {"tags":[],"metadata":{"region":"us-west"}},
702                {"tags":[],"metadata":{"region":"us-east"}}
703            ]"#,
704        )
705        .unwrap();
706
707        let mut out_ptr: *mut c_char = std::ptr::null_mut();
708        let mut out_len: usize = 0;
709        let rc = unsafe {
710            net_predicate_aggregate_debug_report(
711                pred.as_ptr(),
712                contexts.as_ptr(),
713                &mut out_ptr,
714                &mut out_len,
715            )
716        };
717        assert_eq!(rc, 0);
718
719        let report_json = read_and_free(out_ptr);
720        let v: Value = serde_json::from_str(&report_json).unwrap();
721        assert_eq!(v["total_candidates"], 3);
722        assert_eq!(v["matched"], 2);
723        let stats = v["clause_stats"].as_array().unwrap();
724        assert_eq!(stats.len(), 1);
725        assert_eq!(stats[0]["evaluated"], 3);
726        assert_eq!(stats[0]["matched"], 2);
727    }
728
729    /// `redact_metadata_keys` rewrites `MetadataEquals(api_key=...)`
730    /// to `MetadataEquals(api_key=<redacted>)` and leaves
731    /// non-metadata labels untouched.
732    #[test]
733    fn redact_metadata_keys_rewrites_targeted_labels() {
734        let report = CString::new(
735            r#"{
736                "total_candidates": 10,
737                "matched": 4,
738                "clause_stats": [
739                    {"label": "MetadataEquals(api_key=sk-secret-1)", "evaluated": 10, "matched": 4},
740                    {"label": "MetadataEquals(region=us-east)", "evaluated": 10, "matched": 7},
741                    {"label": "Exists(hardware.gpu)", "evaluated": 10, "matched": 8}
742                ]
743            }"#,
744        )
745        .unwrap();
746        let keys = CString::new(r#"["api_key"]"#).unwrap();
747
748        let mut out_ptr: *mut c_char = std::ptr::null_mut();
749        let mut out_len: usize = 0;
750        let rc = unsafe {
751            net_predicate_redact_metadata_keys(
752                report.as_ptr(),
753                keys.as_ptr(),
754                &mut out_ptr,
755                &mut out_len,
756            )
757        };
758        assert_eq!(rc, 0);
759
760        let redacted = read_and_free(out_ptr);
761        let v: Value = serde_json::from_str(&redacted).unwrap();
762        assert_eq!(v["total_candidates"], 10);
763        assert_eq!(v["matched"], 4);
764        let stats = v["clause_stats"].as_array().unwrap();
765        let labels: Vec<&str> = stats.iter().map(|s| s["label"].as_str().unwrap()).collect();
766        assert!(labels.contains(&"MetadataEquals(api_key=<redacted>)"));
767        assert!(labels.contains(&"MetadataEquals(region=us-east)"));
768        assert!(labels.contains(&"Exists(hardware.gpu)"));
769    }
770
771    /// Redaction is idempotent: a second pass with the same keys
772    /// produces the same report.
773    #[test]
774    fn redact_metadata_keys_is_idempotent() {
775        let report = CString::new(
776            r#"{
777                "total_candidates": 5,
778                "matched": 2,
779                "clause_stats": [
780                    {"label": "MetadataEquals(secret=foo)", "evaluated": 5, "matched": 2}
781                ]
782            }"#,
783        )
784        .unwrap();
785        let keys = CString::new(r#"["secret"]"#).unwrap();
786
787        // First pass.
788        let mut out1: *mut c_char = std::ptr::null_mut();
789        let mut len1: usize = 0;
790        unsafe {
791            net_predicate_redact_metadata_keys(report.as_ptr(), keys.as_ptr(), &mut out1, &mut len1)
792        };
793        let pass1 = read_and_free(out1);
794
795        // Second pass over the already-redacted output.
796        let pass1_cs = CString::new(pass1.clone()).unwrap();
797        let mut out2: *mut c_char = std::ptr::null_mut();
798        let mut len2: usize = 0;
799        unsafe {
800            net_predicate_redact_metadata_keys(
801                pass1_cs.as_ptr(),
802                keys.as_ptr(),
803                &mut out2,
804                &mut len2,
805            )
806        };
807        let pass2 = read_and_free(out2);
808
809        assert_eq!(pass1, pass2, "redaction must be idempotent");
810    }
811
812    /// CR-19: redaction works when the metadata key itself contains
813    /// the separator character. Pre-CR-19 `redact_label` split at
814    /// the *first* `=` (or first ` contains "` / ` >= `), so a key
815    /// like `k=v` would split as `k` / `v=actual-secret`, find `k`
816    /// not in the redact set, and silently no-op — leaving the
817    /// secret in the label.
818    #[test]
819    fn redact_label_handles_keys_containing_separator() {
820        let mut keys = BTreeSet::new();
821        keys.insert("weird=key".to_string());
822
823        // First `=` splits at position 5 ("weird"); position 9
824        // ("weird=key") is the right one.
825        let label = "MetadataEquals(weird=key=sk-secret)";
826        let redacted = redact_label(label, &keys);
827        assert_eq!(redacted, "MetadataEquals(weird=key=<redacted>)");
828        assert!(
829            !redacted.contains("sk-secret"),
830            "secret leaked through label-parser heuristic: {redacted}"
831        );
832
833        // Same shape for MetadataNumericAtLeast.
834        let mut keys = BTreeSet::new();
835        keys.insert("a >= b".to_string());
836        let label = "MetadataNumericAtLeast(a >= b >= 42)";
837        let redacted = redact_label(label, &keys);
838        assert_eq!(redacted, "MetadataNumericAtLeast(a >= b >= <redacted>)");
839
840        // Non-targeted keys still pass through unchanged.
841        let label = "MetadataEquals(region=us-east)";
842        let redacted = redact_label(label, &keys);
843        assert_eq!(redacted, label);
844    }
845
846    /// CR-8: `redact_trace_metadata_keys` rewrites metadata-clause
847    /// labels in a trace tree the same way `redact_metadata_keys`
848    /// rewrites them in an aggregated report. Pre-CR-8 the trace
849    /// surface had no redaction sibling, so consumers persisting
850    /// traces from `evaluate_with_trace` had no way to scrub
851    /// secrets.
852    #[test]
853    fn redact_trace_metadata_keys_rewrites_recursively() {
854        // Two-leaf AND with one targeted MetadataEquals leaf and
855        // one untargeted Exists leaf. Trace shape mirrors
856        // `clause_trace_to_wire` output.
857        let trace = CString::new(
858            r#"{
859                "label": "And(2)",
860                "result": true,
861                "children": [
862                    {"label": "MetadataEquals(api_key=sk-secret-1)", "result": true, "children": []},
863                    {"label": "Exists(hardware.gpu)", "result": true, "children": []}
864                ]
865            }"#,
866        )
867        .unwrap();
868        let keys = CString::new(r#"["api_key"]"#).unwrap();
869
870        let mut out_ptr: *mut c_char = std::ptr::null_mut();
871        let mut out_len: usize = 0;
872        let rc = unsafe {
873            net_predicate_redact_trace_metadata_keys(
874                trace.as_ptr(),
875                keys.as_ptr(),
876                &mut out_ptr,
877                &mut out_len,
878            )
879        };
880        assert_eq!(rc, 0);
881
882        let redacted = read_and_free(out_ptr);
883        let v: Value = serde_json::from_str(&redacted).unwrap();
884        assert_eq!(v["label"], "And(2)");
885        assert_eq!(v["result"], true);
886        let children = v["children"].as_array().unwrap();
887        assert_eq!(children.len(), 2);
888        assert_eq!(
889            children[0]["label"], "MetadataEquals(api_key=<redacted>)",
890            "targeted leaf must be redacted"
891        );
892        assert_eq!(
893            children[1]["label"], "Exists(hardware.gpu)",
894            "non-metadata leaf must pass through"
895        );
896        // Verify the secret literal is gone from the entire output.
897        assert!(
898            !redacted.contains("sk-secret-1"),
899            "secret value still present in redacted trace: {redacted}"
900        );
901    }
902
903    /// Idempotent: redacting an already-redacted trace is a no-op.
904    #[test]
905    fn redact_trace_metadata_keys_is_idempotent() {
906        let trace = CString::new(
907            r#"{
908                "label": "MetadataEquals(secret=foo)",
909                "result": false,
910                "children": []
911            }"#,
912        )
913        .unwrap();
914        let keys = CString::new(r#"["secret"]"#).unwrap();
915
916        let mut out1: *mut c_char = std::ptr::null_mut();
917        let mut len1: usize = 0;
918        unsafe {
919            net_predicate_redact_trace_metadata_keys(
920                trace.as_ptr(),
921                keys.as_ptr(),
922                &mut out1,
923                &mut len1,
924            )
925        };
926        let pass1 = read_and_free(out1);
927        let pass1_cs = CString::new(pass1.clone()).unwrap();
928
929        let mut out2: *mut c_char = std::ptr::null_mut();
930        let mut len2: usize = 0;
931        unsafe {
932            net_predicate_redact_trace_metadata_keys(
933                pass1_cs.as_ptr(),
934                keys.as_ptr(),
935                &mut out2,
936                &mut len2,
937            )
938        };
939        let pass2 = read_and_free(out2);
940        assert_eq!(pass1, pass2);
941    }
942
943    /// NULL inputs return `NullPointer` from each function.
944    #[test]
945    fn null_inputs_return_null_pointer_across_all_three() {
946        let pred = CString::new(r#"{"nodes":[],"root_idx":0}"#).unwrap();
947        let tags = CString::new(r#"[]"#).unwrap();
948        let meta = CString::new(r#"{}"#).unwrap();
949        let ctxs = CString::new(r#"[]"#).unwrap();
950        let report =
951            CString::new(r#"{"total_candidates":0,"matched":0,"clause_stats":[]}"#).unwrap();
952        let keys = CString::new(r#"[]"#).unwrap();
953
954        let mut result: c_int = 0;
955        let mut out_ptr: *mut c_char = std::ptr::null_mut();
956        let mut out_len: usize = 0;
957
958        // evaluate_with_trace
959        assert!(
960            unsafe {
961                net_predicate_evaluate_with_trace(
962                    std::ptr::null(),
963                    tags.as_ptr(),
964                    meta.as_ptr(),
965                    &mut result,
966                    &mut out_ptr,
967                    &mut out_len,
968                )
969            } < 0
970        );
971
972        // aggregate_debug_report
973        assert!(
974            unsafe {
975                net_predicate_aggregate_debug_report(
976                    pred.as_ptr(),
977                    std::ptr::null(),
978                    &mut out_ptr,
979                    &mut out_len,
980                )
981            } < 0
982        );
983
984        // redact_metadata_keys
985        assert!(
986            unsafe {
987                net_predicate_redact_metadata_keys(
988                    report.as_ptr(),
989                    std::ptr::null(),
990                    &mut out_ptr,
991                    &mut out_len,
992                )
993            } < 0
994        );
995        // Also check report null
996        assert!(
997            unsafe {
998                net_predicate_redact_metadata_keys(
999                    std::ptr::null(),
1000                    keys.as_ptr(),
1001                    &mut out_ptr,
1002                    &mut out_len,
1003                )
1004            } < 0
1005        );
1006        // Avoid `unused` on ctxs
1007        let _ = ctxs;
1008    }
1009}