Skip to main content

radixdb_plugin/
testing.rs

1use std::{cmp::Ordering, fmt::Debug};
2
3use radixdb_plugin_abi as abi;
4use sha2::{Digest, Sha256};
5
6use crate::{
7    BoundedBytes, BoundedText, CodecReader, CodecWriter, PluginError, PluginResult, RadixType,
8};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct TypeTestReport {
12    pub corpus_values: usize,
13    pub codec_vectors: Vec<Vec<u8>>,
14    pub hash_vectors: Vec<[u8; 32]>,
15}
16
17/// Core scalar ordering used to prove that an operator-class key encoder
18/// preserves the semantic relation declared by its external input type.
19pub trait OperatorClassKey {
20    fn key_compare(&self, other: &Self) -> Ordering;
21}
22
23macro_rules! ordered_key {
24    ($($type:ty),+ $(,)?) => {
25        $(
26            impl OperatorClassKey for $type {
27                fn key_compare(&self, other: &Self) -> Ordering {
28                    self.cmp(other)
29                }
30            }
31        )+
32    };
33}
34
35ordered_key!(i8, i16, i32, i64, u8, u16, u32, u64, bool);
36
37impl OperatorClassKey for f32 {
38    fn key_compare(&self, other: &Self) -> Ordering {
39        self.total_cmp(other)
40    }
41}
42
43impl OperatorClassKey for f64 {
44    fn key_compare(&self, other: &Self) -> Ordering {
45        self.total_cmp(other)
46    }
47}
48
49impl<const MAX: usize> OperatorClassKey for BoundedBytes<MAX> {
50    fn key_compare(&self, other: &Self) -> Ordering {
51        self.as_slice().cmp(other.as_slice())
52    }
53}
54
55impl<const MAX: usize> OperatorClassKey for BoundedText<MAX> {
56    fn key_compare(&self, other: &Self) -> Ordering {
57        self.as_str().cmp(other.as_str())
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TestValue {
63    type_ref: abi::RadixAbiTypeRefV1,
64    bytes: Vec<u8>,
65    is_null: bool,
66}
67
68impl TestValue {
69    pub fn integer(value: i64) -> Self {
70        Self {
71            type_ref: abi::RadixAbiTypeRefV1::builtin(abi::RADIX_BUILTIN_INTEGER),
72            bytes: value.to_le_bytes().to_vec(),
73            is_null: false,
74        }
75    }
76
77    pub fn float(value: f64) -> Self {
78        Self {
79            type_ref: abi::RadixAbiTypeRefV1::builtin(abi::RADIX_BUILTIN_FLOAT),
80            bytes: value.to_bits().to_le_bytes().to_vec(),
81            is_null: false,
82        }
83    }
84
85    pub fn boolean(value: bool) -> Self {
86        Self {
87            type_ref: abi::RadixAbiTypeRefV1::builtin(abi::RADIX_BUILTIN_BOOLEAN),
88            bytes: vec![u8::from(value)],
89            is_null: false,
90        }
91    }
92
93    pub fn external<T: RadixType>(
94        package: &'static abi::RadixPluginDescriptorV1,
95        value: &T,
96    ) -> PluginResult<Self> {
97        let descriptor = find_type(package, T::LOCAL_ID)?;
98        if descriptor.codec_version != T::CODEC_VERSION {
99            return Err(PluginError::invalid_input(
100                "test type codec differs from package descriptor",
101            ));
102        }
103        Ok(Self {
104            type_ref: abi::RadixAbiTypeRefV1::external(
105                descriptor.object_id,
106                descriptor.codec_version,
107            ),
108            bytes: encode(value)?,
109            is_null: false,
110        })
111    }
112
113    pub fn null_like(value: &Self) -> Self {
114        Self {
115            type_ref: value.type_ref,
116            bytes: Vec::new(),
117            is_null: true,
118        }
119    }
120
121    pub fn type_ref(&self) -> abi::RadixAbiTypeRefV1 {
122        self.type_ref
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct TestCallOptions {
128    pub cancelled: bool,
129    pub deadline_expired: bool,
130    pub max_output_bytes: u32,
131    pub max_work_units: u32,
132}
133
134impl Default for TestCallOptions {
135    fn default() -> Self {
136        Self {
137            cancelled: false,
138            deadline_expired: false,
139            max_output_bytes: 1024 * 1024,
140            max_work_units: 1024 * 1024,
141        }
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct TestOutput {
147    pub is_null: bool,
148    pub bytes: Vec<u8>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct TestDiagnostic {
153    pub category: u32,
154    pub status: abi::RadixAbiStatusV1,
155    pub detail: String,
156    pub field: String,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct TestCallReport {
161    pub status: abi::RadixAbiStatusV1,
162    pub outputs: Vec<TestOutput>,
163    pub diagnostics: Vec<TestDiagnostic>,
164    pub work_charged: u32,
165    pub finished: bool,
166}
167
168pub fn validate_descriptor_graph(
169    package: &'static abi::RadixPluginDescriptorV1,
170) -> PluginResult<()> {
171    abi::validate_package_descriptor_shallow(package).map_err(validation_error)?;
172    // SAFETY: shallow validation established the pointer/count shape of every
173    // immutable process-lifetime descriptor table generated by the SDK.
174    unsafe {
175        for item in descriptor_slice(package.types, package.type_count) {
176            abi::validate_external_type_descriptor(item).map_err(validation_error)?;
177        }
178        for item in descriptor_slice(package.functions, package.function_count) {
179            abi::validate_scalar_function_descriptor(item).map_err(validation_error)?;
180            for argument in descriptor_slice(item.arguments, item.argument_count) {
181                abi::validate_type_ref(argument).map_err(validation_error)?;
182            }
183        }
184        for item in descriptor_slice(package.operators, package.operator_count) {
185            abi::validate_operator_descriptor(item).map_err(validation_error)?;
186        }
187        for item in descriptor_slice(package.operator_classes, package.operator_class_count) {
188            abi::validate_operator_class_descriptor(item).map_err(validation_error)?;
189        }
190        for item in descriptor_slice(package.planner_support, package.planner_support_count) {
191            abi::validate_planner_support_descriptor(item).map_err(validation_error)?;
192        }
193    }
194    Ok(())
195}
196
197pub fn invoke_scalar(
198    package: &'static abi::RadixPluginDescriptorV1,
199    local_id: &str,
200    arguments: &[TestValue],
201    options: TestCallOptions,
202) -> PluginResult<TestCallReport> {
203    validate_descriptor_graph(package)?;
204    let function = find_function(package, local_id)?;
205    if arguments.len() != function.argument_count as usize {
206        return Err(PluginError::invalid_input(
207            "test scalar argument count mismatch",
208        ));
209    }
210    let expected = unsafe { descriptor_slice(function.arguments, function.argument_count) };
211    for (argument, expected) in arguments.iter().zip(expected) {
212        if argument.type_ref != *expected {
213            return Err(PluginError::invalid_input(
214                "test scalar argument type mismatch",
215            ));
216        }
217    }
218    let raw_arguments = arguments.iter().map(raw_value).collect::<Vec<_>>();
219    let callback = function
220        .scalar
221        .ok_or_else(|| PluginError::internal("scalar descriptor has no callback"))?;
222    let mut state = TestHostState::new(options);
223    let diagnostic_sink = state.diagnostic_sink();
224    let context = state.call_context(&diagnostic_sink, function.max_output_bytes);
225    let result_builder = state.result_builder(function.max_output_bytes, 1);
226    let argument_pointer = if raw_arguments.is_empty() {
227        std::ptr::null()
228    } else {
229        raw_arguments.as_ptr()
230    };
231    let status = unsafe {
232        callback(
233            &context,
234            argument_pointer,
235            raw_arguments.len() as u32,
236            &result_builder,
237        )
238    };
239    Ok(state.report(status))
240}
241
242pub fn invoke_batch(
243    package: &'static abi::RadixPluginDescriptorV1,
244    local_id: &str,
245    rows: &[Vec<TestValue>],
246    options: TestCallOptions,
247) -> PluginResult<TestCallReport> {
248    validate_descriptor_graph(package)?;
249    let function = find_function(package, local_id)?;
250    let callback = function
251        .batch
252        .ok_or_else(|| PluginError::invalid_input("function has no batch adapter"))?;
253    if rows.len() > u32::MAX as usize
254        || rows
255            .iter()
256            .any(|row| row.len() != function.argument_count as usize)
257    {
258        return Err(PluginError::invalid_input("test batch shape mismatch"));
259    }
260    let expected = unsafe { descriptor_slice(function.arguments, function.argument_count) };
261    for row in rows {
262        for (value, expected) in row.iter().zip(expected) {
263            if value.type_ref != *expected {
264                return Err(PluginError::invalid_input("test batch type mismatch"));
265            }
266        }
267    }
268    let columns = expected
269        .iter()
270        .enumerate()
271        .map(|(index, type_ref)| {
272            let values = rows.iter().map(|row| &row[index]).collect::<Vec<_>>();
273            OwnedColumn::new(package, *type_ref, &values)
274        })
275        .collect::<PluginResult<Vec<_>>>()?;
276    let raw_columns = columns
277        .iter()
278        .map(|column| column.as_abi(rows.len() as u32))
279        .collect::<Vec<_>>();
280    let batch = abi::RadixAbiBatchViewV1 {
281        header: abi::RadixAbiHeaderV1::new::<abi::RadixAbiBatchViewV1>(0),
282        row_count: rows.len() as u32,
283        column_count: raw_columns.len() as u32,
284        columns: if raw_columns.is_empty() {
285            std::ptr::null()
286        } else {
287            raw_columns.as_ptr()
288        },
289    };
290    unsafe { abi::validate_batch_columns(&batch) }.map_err(validation_error)?;
291    let mut state = TestHostState::new(options);
292    let diagnostic_sink = state.diagnostic_sink();
293    let batch_output_bytes = function.max_output_bytes.saturating_mul(rows.len() as u32);
294    let context = state.call_context(&diagnostic_sink, batch_output_bytes);
295    let result_builder = state.result_builder(batch_output_bytes, rows.len() as u32);
296    let status = unsafe { callback(&context, &batch, &result_builder) };
297    Ok(state.report(status))
298}
299
300pub fn invoke_planner(
301    package: &'static abi::RadixPluginDescriptorV1,
302    local_id: &str,
303    predicate: &[u8],
304    options: TestCallOptions,
305) -> PluginResult<TestCallReport> {
306    validate_descriptor_graph(package)?;
307    let support = find_planner_support(package, local_id)?;
308    let callback = support
309        .callback
310        .ok_or_else(|| PluginError::internal("planner descriptor has no callback"))?;
311    let mut state = TestHostState::new(options);
312    let diagnostic_sink = state.diagnostic_sink();
313    let context = state.call_context(&diagnostic_sink, support.max_output_bytes);
314    let result_builder = state.result_builder(
315        support.max_output_bytes,
316        support.max_spans.saturating_add(1),
317    );
318    let predicate = abi::RadixAbiSliceV1 {
319        ptr: if predicate.is_empty() {
320            std::ptr::null()
321        } else {
322            predicate.as_ptr()
323        },
324        len: predicate.len() as u32,
325        reserved: 0,
326    };
327    let status = unsafe { callback(&context, predicate, &result_builder) };
328    Ok(state.report(status))
329}
330
331pub fn planner_recheck_policy(
332    package: &'static abi::RadixPluginDescriptorV1,
333    local_id: &str,
334) -> PluginResult<u16> {
335    validate_descriptor_graph(package)?;
336    Ok(find_planner_support(package, local_id)?.recheck_policy)
337}
338
339pub fn decode_candidate_spans(report: &TestCallReport) -> PluginResult<Vec<crate::CandidateSpan>> {
340    report
341        .outputs
342        .iter()
343        .filter(|item| item.bytes.first() == Some(&1))
344        .map(|item| {
345            if item.is_null || item.bytes.len() < 12 || item.bytes[..4] != [1, 0, 0, 0] {
346                return Err(PluginError::invalid_input(
347                    "planner output is not a candidate span",
348                ));
349            }
350            let start_len =
351                u32::from_le_bytes(item.bytes[4..8].try_into().expect("fixed width")) as usize;
352            let end_len =
353                u32::from_le_bytes(item.bytes[8..12].try_into().expect("fixed width")) as usize;
354            let split = 12_usize
355                .checked_add(start_len)
356                .ok_or_else(|| PluginError::invalid_input("candidate span length overflow"))?;
357            let end = split
358                .checked_add(end_len)
359                .ok_or_else(|| PluginError::invalid_input("candidate span length overflow"))?;
360            if end != item.bytes.len() {
361                return Err(PluginError::invalid_input(
362                    "candidate span has invalid lengths",
363                ));
364            }
365            Ok(crate::CandidateSpan {
366                start: item.bytes[12..split].to_vec(),
367                end: item.bytes[split..end].to_vec(),
368            })
369        })
370        .collect()
371}
372
373pub fn check_type<T>() -> PluginResult<TypeTestReport>
374where
375    T: RadixType + Debug,
376{
377    let corpus = T::test_corpus();
378    if corpus.is_empty() {
379        return Err(PluginError::invalid_input(
380            "type test corpus must not be empty",
381        ));
382    }
383    let mut codec_vectors = Vec::with_capacity(corpus.len());
384    for value in &corpus {
385        let bytes = encode(value)?;
386        let decoded = decode::<T>(&bytes)?;
387        let second = encode(&decoded)?;
388        if bytes != second {
389            return Err(PluginError::domain(
390                "codec is not canonical after roundtrip",
391            ));
392        }
393        codec_vectors.push(bytes);
394    }
395
396    let mut hashes = vec![None; corpus.len()];
397    for (index, value) in corpus.iter().enumerate() {
398        let mut components = Vec::new();
399        let mut sink = crate::HashSink::for_testing(&mut components);
400        if let Some(result) = T::semantic_hash(value, &mut sink) {
401            result?;
402            hashes[index] = Some(hash_components(&components));
403        }
404    }
405
406    if T::CAPABILITIES & radixdb_plugin_abi::RADIX_TYPE_CAP_EQUALITY != 0 {
407        for (left_index, left) in corpus.iter().enumerate() {
408            if T::semantic_equal(left, left) != Some(true) {
409                return Err(PluginError::domain("equality is not reflexive"));
410            }
411            for (right_index, right) in corpus.iter().enumerate() {
412                let lr = T::semantic_equal(left, right).ok_or_else(|| {
413                    PluginError::internal("equality capability has no safe callback")
414                })?;
415                let rl = T::semantic_equal(right, left).ok_or_else(|| {
416                    PluginError::internal("equality capability has no safe callback")
417                })?;
418                if lr != rl {
419                    return Err(PluginError::domain("equality is not symmetric"));
420                }
421                if lr && hashes[left_index] != hashes[right_index] {
422                    return Err(PluginError::domain(
423                        "equal values produce different semantic hash components",
424                    ));
425                }
426                for third in &corpus {
427                    if lr
428                        && T::semantic_equal(right, third) == Some(true)
429                        && T::semantic_equal(left, third) != Some(true)
430                    {
431                        return Err(PluginError::domain("equality is not transitive"));
432                    }
433                }
434            }
435        }
436    }
437
438    if T::CAPABILITIES & radixdb_plugin_abi::RADIX_TYPE_CAP_ORDERING != 0 {
439        for left in &corpus {
440            if T::semantic_compare(left, left) != Some(Ordering::Equal) {
441                return Err(PluginError::domain("ordering is not reflexive"));
442            }
443            for right in &corpus {
444                let lr = T::semantic_compare(left, right)
445                    .ok_or_else(|| PluginError::internal("missing ordering callback"))?;
446                let rl = T::semantic_compare(right, left)
447                    .ok_or_else(|| PluginError::internal("missing ordering callback"))?;
448                if lr != rl.reverse() {
449                    return Err(PluginError::domain("ordering is not antisymmetric"));
450                }
451                if T::CAPABILITIES & radixdb_plugin_abi::RADIX_TYPE_CAP_EQUALITY != 0
452                    && (lr == Ordering::Equal) != (T::semantic_equal(left, right) == Some(true))
453                {
454                    return Err(PluginError::domain(
455                        "ordering equality disagrees with equality callback",
456                    ));
457                }
458                for third in &corpus {
459                    if lr != Ordering::Greater
460                        && T::semantic_compare(right, third) != Some(Ordering::Greater)
461                        && T::semantic_compare(left, third) == Some(Ordering::Greater)
462                    {
463                        return Err(PluginError::domain("ordering is not transitive"));
464                    }
465                }
466            }
467        }
468    }
469
470    Ok(TypeTestReport {
471        corpus_values: corpus.len(),
472        codec_vectors,
473        hash_vectors: hashes.into_iter().flatten().collect(),
474    })
475}
476
477/// Verify the full B-tree law: the physical key is an exact order embedding
478/// of the external type's equality and total-order callbacks for its corpus.
479pub fn check_btree_operator_class<T, K>(encode_key: fn(T) -> PluginResult<K>) -> PluginResult<()>
480where
481    T: RadixType + Debug,
482    K: OperatorClassKey,
483{
484    let report = check_type::<T>()?;
485    if T::CAPABILITIES & abi::RADIX_TYPE_CAP_EQUALITY == 0
486        || T::CAPABILITIES & abi::RADIX_TYPE_CAP_ORDERING == 0
487    {
488        return Err(PluginError::domain(
489            "B-tree operator class requires equality and total ordering",
490        ));
491    }
492    let input = T::test_corpus()
493        .iter()
494        .map(encode)
495        .collect::<PluginResult<Vec<_>>>()?;
496    if input.len() != report.corpus_values {
497        return Err(PluginError::internal(
498            "operator-class corpus changed between law checks",
499        ));
500    }
501    let keys = input
502        .iter()
503        .map(|bytes| decode::<T>(bytes).and_then(encode_key))
504        .collect::<PluginResult<Vec<_>>>()?;
505    for (left_index, left_bytes) in input.iter().enumerate() {
506        for (right_index, right_bytes) in input.iter().enumerate() {
507            let left = decode::<T>(left_bytes)?;
508            let right = decode::<T>(right_bytes)?;
509            let semantic_order = T::semantic_compare(&left, &right)
510                .ok_or_else(|| PluginError::internal("missing ordering callback"))?;
511            let semantic_equal = T::semantic_equal(&left, &right)
512                .ok_or_else(|| PluginError::internal("missing equality callback"))?;
513            let key_order = keys[left_index].key_compare(&keys[right_index]);
514            if key_order != semantic_order || (key_order == Ordering::Equal) != semantic_equal {
515                return Err(PluginError::domain(
516                    "B-tree key encoder does not preserve semantic equality and total order",
517                ));
518            }
519        }
520    }
521    Ok(())
522}
523
524/// Hash classes never select a hash algorithm or physical token. Their law is
525/// exactly the external type equality/hash-component contract.
526pub fn check_hash_operator_class<T>() -> PluginResult<()>
527where
528    T: RadixType + Debug,
529{
530    let report = check_type::<T>()?;
531    if T::CAPABILITIES & abi::RADIX_TYPE_CAP_EQUALITY == 0
532        || T::CAPABILITIES & abi::RADIX_TYPE_CAP_HASH == 0
533        || report.hash_vectors.len() != report.corpus_values
534    {
535        return Err(PluginError::domain(
536            "hash operator class requires equality and semantic hash components",
537        ));
538    }
539    Ok(())
540}
541
542/// Bitmap keys may coalesce byte representation only when the external values
543/// are semantically equal; otherwise exact lookup and uniqueness would diverge.
544pub fn check_bitmap_operator_class<T, K>(encode_key: fn(T) -> PluginResult<K>) -> PluginResult<()>
545where
546    T: RadixType + Debug,
547    K: OperatorClassKey,
548{
549    let _ = check_type::<T>()?;
550    if T::CAPABILITIES & abi::RADIX_TYPE_CAP_EQUALITY == 0 {
551        return Err(PluginError::domain(
552            "bitmap operator class requires equality",
553        ));
554    }
555    let input = T::test_corpus()
556        .iter()
557        .map(encode)
558        .collect::<PluginResult<Vec<_>>>()?;
559    let keys = input
560        .iter()
561        .map(|bytes| decode::<T>(bytes).and_then(encode_key))
562        .collect::<PluginResult<Vec<_>>>()?;
563    for (left_index, left_bytes) in input.iter().enumerate() {
564        for (right_index, right_bytes) in input.iter().enumerate() {
565            let left = decode::<T>(left_bytes)?;
566            let right = decode::<T>(right_bytes)?;
567            let semantic_equal = T::semantic_equal(&left, &right)
568                .ok_or_else(|| PluginError::internal("missing equality callback"))?;
569            let key_equal = keys[left_index].key_compare(&keys[right_index]) == Ordering::Equal;
570            if key_equal != semantic_equal {
571                return Err(PluginError::domain(
572                    "bitmap key encoder does not preserve semantic equality",
573                ));
574            }
575        }
576    }
577    Ok(())
578}
579
580/// Compare every strategy function with the equality/ordering callbacks of
581/// the external input type over the same bounded corpus.
582pub fn check_operator_class_strategies<T>(
583    package: &'static abi::RadixPluginDescriptorV1,
584    local_id: &str,
585) -> PluginResult<()>
586where
587    T: RadixType + Debug,
588{
589    validate_descriptor_graph(package)?;
590    let class = find_operator_class(package, local_id)?;
591    let external_type = find_type(package, T::LOCAL_ID)?;
592    let expected_type =
593        abi::RadixAbiTypeRefV1::external(external_type.object_id, external_type.codec_version);
594    if class.input_type != expected_type {
595        return Err(PluginError::domain(
596            "operator-class input differs from its tested external type",
597        ));
598    }
599    let strategies = unsafe { descriptor_slice(class.strategies, class.strategy_count) };
600    let corpus = T::test_corpus();
601    let values = corpus
602        .iter()
603        .map(|value| TestValue::external(package, value))
604        .collect::<PluginResult<Vec<_>>>()?;
605    let encoded = corpus
606        .iter()
607        .map(encode)
608        .collect::<PluginResult<Vec<_>>>()?;
609    for (left_index, left_bytes) in encoded.iter().enumerate() {
610        for (right_index, right_bytes) in encoded.iter().enumerate() {
611            let left = decode::<T>(left_bytes)?;
612            let right = decode::<T>(right_bytes)?;
613            let equal = T::semantic_equal(&left, &right)
614                .ok_or_else(|| PluginError::internal("missing equality callback"))?;
615            let order = T::semantic_compare(&left, &right);
616            for strategy in strategies {
617                let expected = match (class.access_method, strategy.slot) {
618                    (abi::RADIX_ACCESS_METHOD_BTREE, 1) => order == Some(Ordering::Less),
619                    (abi::RADIX_ACCESS_METHOD_BTREE, 2) => {
620                        order.is_some_and(|value| value != Ordering::Greater)
621                    }
622                    (abi::RADIX_ACCESS_METHOD_BTREE, 3)
623                    | (abi::RADIX_ACCESS_METHOD_HASH, 1)
624                    | (abi::RADIX_ACCESS_METHOD_BITMAP, 1) => equal,
625                    (abi::RADIX_ACCESS_METHOD_BTREE, 4) => {
626                        order.is_some_and(|value| value != Ordering::Less)
627                    }
628                    (abi::RADIX_ACCESS_METHOD_BTREE, 5) => order == Some(Ordering::Greater),
629                    _ => {
630                        return Err(PluginError::domain(
631                            "operator class has an unsupported strategy slot",
632                        ));
633                    }
634                };
635                let actual = invoke_boolean_operator_by_id(
636                    package,
637                    strategy.object_id,
638                    &values[left_index],
639                    &values[right_index],
640                )?;
641                if actual != expected {
642                    return Err(PluginError::domain(
643                        "operator-class strategy disagrees with type semantics",
644                    ));
645                }
646            }
647        }
648    }
649    Ok(())
650}
651
652pub fn golden_vectors<T: RadixType + Debug>() -> PluginResult<Vec<Vec<u8>>> {
653    Ok(check_type::<T>()?.codec_vectors)
654}
655
656pub fn fuzz_malformed_external_bytes<T: RadixType>(inputs: &[&[u8]]) -> usize {
657    inputs
658        .iter()
659        .filter(|bytes| decode::<T>(bytes).is_err())
660        .count()
661}
662
663fn encode<T: RadixType>(value: &T) -> PluginResult<Vec<u8>> {
664    let mut output = CodecWriter::new(T::MAX_BYTES as usize);
665    value.encode(&mut output)?;
666    let bytes = output.into_bytes();
667    if T::STORAGE_KIND == abi::RADIX_EXTERNAL_STORAGE_FIXED
668        && bytes.len() != T::FIXED_BYTES as usize
669    {
670        return Err(PluginError::domain(
671            "fixed codec corpus value has the wrong width",
672        ));
673    }
674    Ok(bytes)
675}
676
677fn decode<T: RadixType>(bytes: &[u8]) -> PluginResult<T> {
678    if T::STORAGE_KIND == abi::RADIX_EXTERNAL_STORAGE_FIXED
679        && bytes.len() != T::FIXED_BYTES as usize
680    {
681        return Err(PluginError::invalid_input(
682            "fixed codec input has the wrong width",
683        ));
684    }
685    let mut input = CodecReader::new(bytes);
686    let value = T::decode(&mut input)?;
687    input.finish()?;
688    Ok(value)
689}
690
691pub(crate) fn hash_components(components: &[(u16, Vec<u8>)]) -> [u8; 32] {
692    let mut digest = Sha256::new();
693    for (kind, bytes) in components {
694        digest.update(kind.to_le_bytes());
695        digest.update((bytes.len() as u32).to_le_bytes());
696        digest.update(bytes);
697    }
698    digest.finalize().into()
699}
700
701fn validation_error(error: abi::RadixAbiValidationError) -> PluginError {
702    PluginError::invalid_input(format!("invalid generated ABI descriptor: {error:?}"))
703}
704
705unsafe fn descriptor_slice<'a, T>(pointer: *const T, count: u32) -> &'a [T] {
706    if count == 0 {
707        &[]
708    } else {
709        // SAFETY: callers first validate the static descriptor table shape.
710        unsafe { std::slice::from_raw_parts(pointer, count as usize) }
711    }
712}
713
714fn abi_text(value: abi::RadixAbiStringV1) -> PluginResult<&'static str> {
715    if value.len == 0 || value.ptr.is_null() {
716        return Err(PluginError::invalid_input(
717            "empty generated descriptor name",
718        ));
719    }
720    // SAFETY: generated descriptor strings have static lifetime and the graph
721    // validator checks their pointer/length shape before lookup.
722    let bytes = unsafe { std::slice::from_raw_parts(value.ptr, value.len as usize) };
723    std::str::from_utf8(bytes)
724        .map_err(|_| PluginError::invalid_input("generated descriptor name is not UTF-8"))
725}
726
727fn find_type(
728    package: &'static abi::RadixPluginDescriptorV1,
729    local_id: &str,
730) -> PluginResult<&'static abi::RadixAbiExternalTypeDescriptorV1> {
731    validate_descriptor_graph(package)?;
732    let types = unsafe { descriptor_slice(package.types, package.type_count) };
733    types
734        .iter()
735        .find(|descriptor| abi_text(descriptor.local_id) == Ok(local_id))
736        .ok_or_else(|| PluginError::invalid_input("unknown test external type"))
737}
738
739fn find_function(
740    package: &'static abi::RadixPluginDescriptorV1,
741    local_id: &str,
742) -> PluginResult<&'static abi::RadixAbiScalarFunctionDescriptorV1> {
743    let functions = unsafe { descriptor_slice(package.functions, package.function_count) };
744    functions
745        .iter()
746        .find(|descriptor| abi_text(descriptor.local_id) == Ok(local_id))
747        .ok_or_else(|| PluginError::invalid_input("unknown test scalar function"))
748}
749
750fn find_function_by_id(
751    package: &'static abi::RadixPluginDescriptorV1,
752    object_id: [u8; 16],
753) -> PluginResult<&'static abi::RadixAbiScalarFunctionDescriptorV1> {
754    let functions = unsafe { descriptor_slice(package.functions, package.function_count) };
755    functions
756        .iter()
757        .find(|descriptor| descriptor.object_id == object_id)
758        .ok_or_else(|| PluginError::invalid_input("unknown test scalar function identity"))
759}
760
761fn find_operator_by_id(
762    package: &'static abi::RadixPluginDescriptorV1,
763    object_id: [u8; 16],
764) -> PluginResult<&'static abi::RadixAbiOperatorDescriptorV1> {
765    let operators = unsafe { descriptor_slice(package.operators, package.operator_count) };
766    operators
767        .iter()
768        .find(|descriptor| descriptor.object_id == object_id)
769        .ok_or_else(|| PluginError::invalid_input("unknown test operator identity"))
770}
771
772fn find_operator_class(
773    package: &'static abi::RadixPluginDescriptorV1,
774    local_id: &str,
775) -> PluginResult<&'static abi::RadixAbiOperatorClassDescriptorV1> {
776    let classes =
777        unsafe { descriptor_slice(package.operator_classes, package.operator_class_count) };
778    classes
779        .iter()
780        .find(|descriptor| abi_text(descriptor.local_id) == Ok(local_id))
781        .ok_or_else(|| PluginError::invalid_input("unknown test operator class"))
782}
783
784fn invoke_boolean_operator_by_id(
785    package: &'static abi::RadixPluginDescriptorV1,
786    object_id: [u8; 16],
787    left: &TestValue,
788    right: &TestValue,
789) -> PluginResult<bool> {
790    let operator = find_operator_by_id(package, object_id)?;
791    let function = find_function_by_id(package, operator.function_id)?;
792    let report = invoke_scalar(
793        package,
794        abi_text(function.local_id)?,
795        &[left.clone(), right.clone()],
796        TestCallOptions::default(),
797    )?;
798    if report.status != abi::RADIX_STATUS_OK
799        || !report.finished
800        || report.outputs.len() != 1
801        || report.outputs[0].is_null
802    {
803        return Err(PluginError::domain(
804            "operator-class strategy did not return one BOOLEAN result",
805        ));
806    }
807    match report.outputs[0].bytes.as_slice() {
808        [0] => Ok(false),
809        [1] => Ok(true),
810        _ => Err(PluginError::domain(
811            "operator-class strategy returned a malformed BOOLEAN",
812        )),
813    }
814}
815
816fn find_planner_support(
817    package: &'static abi::RadixPluginDescriptorV1,
818    local_id: &str,
819) -> PluginResult<&'static abi::RadixAbiPlannerSupportDescriptorV1> {
820    let supports =
821        unsafe { descriptor_slice(package.planner_support, package.planner_support_count) };
822    supports
823        .iter()
824        .find(|descriptor| abi_text(descriptor.local_id) == Ok(local_id))
825        .ok_or_else(|| PluginError::invalid_input("unknown test planner support"))
826}
827
828fn raw_value(value: &TestValue) -> abi::RadixAbiValueV1 {
829    let mut inline_bytes = [0; 16];
830    let fixed_builtin = value.type_ref.kind == abi::RADIX_TYPE_REF_BUILTIN
831        && matches!(
832            value.type_ref.builtin_tag,
833            abi::RADIX_BUILTIN_INTEGER | abi::RADIX_BUILTIN_FLOAT | abi::RADIX_BUILTIN_BOOLEAN
834        );
835    if fixed_builtin && !value.is_null {
836        inline_bytes[..value.bytes.len()].copy_from_slice(&value.bytes);
837    }
838    abi::RadixAbiValueV1 {
839        type_ref: value.type_ref,
840        flags: if value.is_null {
841            abi::RADIX_VALUE_FLAG_NULL
842        } else {
843            0
844        },
845        reserved: 0,
846        inline_bytes,
847        borrowed_bytes: if fixed_builtin || value.is_null {
848            abi::RadixAbiSliceV1::EMPTY
849        } else {
850            abi::RadixAbiSliceV1 {
851                ptr: value.bytes.as_ptr(),
852                len: value.bytes.len() as u32,
853                reserved: 0,
854            }
855        },
856    }
857}
858
859enum ColumnStorage {
860    Aligned(Vec<u64>),
861    Bytes(Vec<u8>),
862}
863
864impl ColumnStorage {
865    fn bytes(&self) -> (*const u8, usize) {
866        match self {
867            Self::Aligned(words) => (words.as_ptr().cast(), words.len() * 8),
868            Self::Bytes(bytes) => (bytes.as_ptr(), bytes.len()),
869        }
870    }
871}
872
873struct OwnedColumn {
874    type_ref: abi::RadixAbiTypeRefV1,
875    layout: u16,
876    element_width: u16,
877    alignment: u16,
878    stride: u32,
879    null_bitmap: Vec<u8>,
880    storage: ColumnStorage,
881    offsets: Vec<u32>,
882}
883
884impl OwnedColumn {
885    fn new(
886        package: &'static abi::RadixPluginDescriptorV1,
887        type_ref: abi::RadixAbiTypeRefV1,
888        values: &[&TestValue],
889    ) -> PluginResult<Self> {
890        let fixed_width = if type_ref.kind == abi::RADIX_TYPE_REF_BUILTIN {
891            match type_ref.builtin_tag {
892                abi::RADIX_BUILTIN_INTEGER | abi::RADIX_BUILTIN_FLOAT => Some(8_usize),
893                abi::RADIX_BUILTIN_BOOLEAN => Some(1_usize),
894                _ => None,
895            }
896        } else {
897            let types = unsafe { descriptor_slice(package.types, package.type_count) };
898            types
899                .iter()
900                .find(|descriptor| descriptor.object_id == type_ref.object_id)
901                .and_then(|descriptor| {
902                    (descriptor.storage_kind == abi::RADIX_EXTERNAL_STORAGE_FIXED)
903                        .then_some(descriptor.fixed_bytes as usize)
904                })
905        };
906        let mut null_bitmap = vec![0_u8; values.len().div_ceil(8)];
907        for (index, value) in values.iter().enumerate() {
908            if value.is_null {
909                null_bitmap[index / 8] |= 1 << (index % 8);
910            }
911        }
912        if null_bitmap.iter().all(|byte| *byte == 0) {
913            null_bitmap.clear();
914        }
915
916        if let Some(width) = fixed_width {
917            if width == 0 || width > u16::MAX as usize {
918                return Err(PluginError::limit_exceeded(
919                    "fixed test column width is outside ABI bounds",
920                ));
921            }
922            if width == 1 {
923                let data = values
924                    .iter()
925                    .map(|value| {
926                        if value.is_null {
927                            Ok(0)
928                        } else if value.bytes.len() == 1 {
929                            Ok(value.bytes[0])
930                        } else {
931                            Err(PluginError::invalid_input(
932                                "fixed test value has wrong width",
933                            ))
934                        }
935                    })
936                    .collect::<PluginResult<Vec<_>>>()?;
937                return Ok(Self {
938                    type_ref,
939                    layout: abi::RADIX_COLUMN_LAYOUT_FIXED,
940                    element_width: 1,
941                    alignment: 1,
942                    stride: 1,
943                    null_bitmap,
944                    storage: ColumnStorage::Bytes(data),
945                    offsets: Vec::new(),
946                });
947            }
948            let stride = width.div_ceil(8) * 8;
949            let total = stride
950                .checked_mul(values.len())
951                .ok_or_else(|| PluginError::limit_exceeded("test column size overflow"))?;
952            let mut words = vec![0_u64; total / 8];
953            // SAFETY: Vec<u64> owns `total` initialized bytes and gives the
954            // alignment declared in the ABI column below.
955            let bytes =
956                unsafe { std::slice::from_raw_parts_mut(words.as_mut_ptr().cast::<u8>(), total) };
957            for (row, value) in values.iter().enumerate() {
958                if !value.is_null {
959                    if value.bytes.len() != width {
960                        return Err(PluginError::invalid_input(
961                            "fixed test value has wrong width",
962                        ));
963                    }
964                    let start = row * stride;
965                    bytes[start..start + width].copy_from_slice(&value.bytes);
966                }
967            }
968            return Ok(Self {
969                type_ref,
970                layout: abi::RADIX_COLUMN_LAYOUT_FIXED,
971                element_width: width as u16,
972                alignment: 8,
973                stride: stride as u32,
974                null_bitmap,
975                storage: ColumnStorage::Aligned(words),
976                offsets: Vec::new(),
977            });
978        }
979
980        let mut data = Vec::new();
981        let mut offsets = Vec::with_capacity(values.len() + 1);
982        offsets.push(0);
983        for value in values {
984            if !value.is_null {
985                data.extend_from_slice(&value.bytes);
986            }
987            offsets.push(
988                u32::try_from(data.len())
989                    .map_err(|_| PluginError::limit_exceeded("test column exceeds ABI bounds"))?,
990            );
991        }
992        Ok(Self {
993            type_ref,
994            layout: abi::RADIX_COLUMN_LAYOUT_VARIABLE,
995            element_width: 0,
996            alignment: 1,
997            stride: 0,
998            null_bitmap,
999            storage: ColumnStorage::Bytes(data),
1000            offsets,
1001        })
1002    }
1003
1004    fn as_abi(&self, row_count: u32) -> abi::RadixAbiColumnViewV1 {
1005        let (data, data_len) = self.storage.bytes();
1006        abi::RadixAbiColumnViewV1 {
1007            header: abi::RadixAbiHeaderV1::new::<abi::RadixAbiColumnViewV1>(0),
1008            type_ref: self.type_ref,
1009            row_count,
1010            layout: self.layout,
1011            element_width: self.element_width,
1012            alignment: self.alignment,
1013            reserved_u16: 0,
1014            stride: self.stride,
1015            null_bitmap: abi::RadixAbiSliceV1 {
1016                ptr: if self.null_bitmap.is_empty() {
1017                    std::ptr::null()
1018                } else {
1019                    self.null_bitmap.as_ptr()
1020                },
1021                len: self.null_bitmap.len() as u32,
1022                reserved: 0,
1023            },
1024            data: abi::RadixAbiSliceV1 {
1025                ptr: if data_len == 0 {
1026                    std::ptr::null()
1027                } else {
1028                    data
1029                },
1030                len: data_len as u32,
1031                reserved: 0,
1032            },
1033            offsets: abi::RadixAbiU32SliceV1 {
1034                ptr: if self.offsets.is_empty() {
1035                    std::ptr::null()
1036                } else {
1037                    self.offsets.as_ptr()
1038                },
1039                len: self.offsets.len() as u32,
1040                reserved: 0,
1041            },
1042        }
1043    }
1044}
1045
1046struct TestHostState {
1047    options: TestCallOptions,
1048    staged: Vec<TestOutput>,
1049    committed: Vec<TestOutput>,
1050    diagnostics: Vec<TestDiagnostic>,
1051    work_charged: u32,
1052    finished: bool,
1053}
1054
1055impl TestHostState {
1056    fn new(options: TestCallOptions) -> Self {
1057        Self {
1058            options,
1059            staged: Vec::new(),
1060            committed: Vec::new(),
1061            diagnostics: Vec::new(),
1062            work_charged: 0,
1063            finished: false,
1064        }
1065    }
1066
1067    fn diagnostic_sink(&mut self) -> abi::RadixAbiDiagnosticSinkV1 {
1068        let handle = self as *mut Self as usize as u64;
1069        abi::RadixAbiDiagnosticSinkV1 {
1070            header: abi::RadixAbiHeaderV1::new::<abi::RadixAbiDiagnosticSinkV1>(0),
1071            handle,
1072            max_detail_bytes: abi::RADIX_MAX_DIAGNOSTIC_BYTES,
1073            reserved: 0,
1074            write: Some(test_diagnostic),
1075        }
1076    }
1077
1078    fn call_context(
1079        &mut self,
1080        diagnostic_sink: &abi::RadixAbiDiagnosticSinkV1,
1081        declared_output_bytes: u32,
1082    ) -> abi::RadixAbiCallContextV1 {
1083        let handle = self as *mut Self as usize as u64;
1084        abi::RadixAbiCallContextV1 {
1085            header: abi::RadixAbiHeaderV1::new::<abi::RadixAbiCallContextV1>(0),
1086            handle,
1087            deadline_unix_ns: if self.options.deadline_expired {
1088                1
1089            } else {
1090                u64::MAX
1091            },
1092            max_output_bytes: self.options.max_output_bytes.min(declared_output_bytes),
1093            max_work_units: self.options.max_work_units,
1094            check_cancelled: Some(test_cancelled),
1095            charge_work: Some(test_charge_work),
1096            diagnostics: diagnostic_sink,
1097        }
1098    }
1099
1100    fn result_builder(
1101        &mut self,
1102        declared_output_bytes: u32,
1103        max_items: u32,
1104    ) -> abi::RadixAbiResultBuilderV1 {
1105        let handle = self as *mut Self as usize as u64;
1106        abi::RadixAbiResultBuilderV1 {
1107            header: abi::RadixAbiHeaderV1::new::<abi::RadixAbiResultBuilderV1>(0),
1108            handle,
1109            max_bytes: self.options.max_output_bytes.min(declared_output_bytes),
1110            max_items,
1111            write: Some(test_write),
1112            finish: Some(test_finish),
1113        }
1114    }
1115
1116    fn report(self, status: abi::RadixAbiStatusV1) -> TestCallReport {
1117        TestCallReport {
1118            status,
1119            outputs: self.committed,
1120            diagnostics: self.diagnostics,
1121            work_charged: self.work_charged,
1122            finished: self.finished,
1123        }
1124    }
1125}
1126
1127unsafe fn state(handle: u64) -> &'static mut TestHostState {
1128    // SAFETY: every local-host ABI object carries the live TestHostState
1129    // address and callbacks are synchronous.
1130    unsafe { &mut *(handle as usize as *mut TestHostState) }
1131}
1132
1133unsafe extern "C" fn test_cancelled(handle: u64) -> abi::RadixAbiStatusV1 {
1134    let options = unsafe { state(handle) }.options;
1135    if options.cancelled || options.deadline_expired {
1136        abi::RADIX_STATUS_CANCELLED
1137    } else {
1138        abi::RADIX_STATUS_OK
1139    }
1140}
1141
1142unsafe extern "C" fn test_charge_work(handle: u64, units: u32) -> abi::RadixAbiStatusV1 {
1143    let state = unsafe { state(handle) };
1144    let Some(total) = state.work_charged.checked_add(units) else {
1145        return abi::RADIX_STATUS_LIMIT_EXCEEDED;
1146    };
1147    if total > state.options.max_work_units {
1148        abi::RADIX_STATUS_LIMIT_EXCEEDED
1149    } else {
1150        state.work_charged = total;
1151        abi::RADIX_STATUS_OK
1152    }
1153}
1154
1155unsafe extern "C" fn test_write(
1156    handle: u64,
1157    flags: u32,
1158    reserved: u32,
1159    bytes: abi::RadixAbiSliceV1,
1160) -> abi::RadixAbiStatusV1 {
1161    let state = unsafe { state(handle) };
1162    if abi::validate_result_item(flags, reserved, bytes, state.options.max_output_bytes).is_err() {
1163        return abi::RADIX_STATUS_CONTRACT_VIOLATION;
1164    }
1165    let bytes = if bytes.len == 0 {
1166        Vec::new()
1167    } else {
1168        // SAFETY: plugin result bytes remain live for this synchronous copy.
1169        unsafe { std::slice::from_raw_parts(bytes.ptr, bytes.len as usize) }.to_vec()
1170    };
1171    state.staged.push(TestOutput {
1172        is_null: flags & abi::RADIX_RESULT_ITEM_FLAG_NULL != 0,
1173        bytes,
1174    });
1175    abi::RADIX_STATUS_OK
1176}
1177
1178unsafe extern "C" fn test_finish(handle: u64) -> abi::RadixAbiStatusV1 {
1179    let state = unsafe { state(handle) };
1180    if state.finished {
1181        return abi::RADIX_STATUS_CONTRACT_VIOLATION;
1182    }
1183    state.finished = true;
1184    state.committed = std::mem::take(&mut state.staged);
1185    abi::RADIX_STATUS_OK
1186}
1187
1188unsafe extern "C" fn test_diagnostic(
1189    handle: u64,
1190    diagnostic: *const abi::RadixAbiDiagnosticV1,
1191) -> abi::RadixAbiStatusV1 {
1192    let Some(diagnostic) = (unsafe { diagnostic.as_ref() }) else {
1193        return abi::RADIX_STATUS_INVALID_ARGUMENT;
1194    };
1195    if abi::validate_diagnostic(diagnostic).is_err() {
1196        return abi::RADIX_STATUS_CONTRACT_VIOLATION;
1197    }
1198    let copy = |value: abi::RadixAbiStringV1| {
1199        if value.len == 0 {
1200            String::new()
1201        } else {
1202            // SAFETY: diagnostic slices remain live for this synchronous copy.
1203            String::from_utf8_lossy(unsafe {
1204                std::slice::from_raw_parts(value.ptr, value.len as usize)
1205            })
1206            .into_owned()
1207        }
1208    };
1209    unsafe { state(handle) }.diagnostics.push(TestDiagnostic {
1210        category: diagnostic.category,
1211        status: diagnostic.status,
1212        detail: copy(diagnostic.detail),
1213        field: copy(diagnostic.field),
1214    });
1215    abi::RADIX_STATUS_OK
1216}