axioval_rules/
property_rules.rs

1//! Trusted property capabilities over exact host-provided resolutions.
2
3use axioval_engine::{
4    CapabilityEvaluation, CompiledRule, NotEvaluatedReason, ParameterDescriptor, ParameterType,
5    PropertyRequest, PropertyResolution, PropertyResolutionServiceHandle, RuleCapability,
6    RuleContext,
7};
8use axioval_ir::contract::ParameterValue;
9use axioval_ir::{Evidence, Finding, Object, PropertyValue, Severity};
10
11use crate::selection::{property_error, select_objects};
12
13fn property_reference<'a>(
14    rule: &'a CompiledRule,
15    name: &str,
16) -> Option<(Option<&'a str>, &'a str)> {
17    match rule.parameters.get(name)? {
18        ParameterValue::PropertyReference {
19            property,
20            property_set,
21        } => Some((property_set.as_deref(), property)),
22        _ => None,
23    }
24}
25
26fn string<'a>(rule: &'a CompiledRule, name: &str) -> Option<&'a str> {
27    match rule.parameters.get(name)? {
28        ParameterValue::String { value }
29        | ParameterValue::Enum { value }
30        | ParameterValue::Reference { value } => Some(value),
31        _ => None,
32    }
33}
34
35fn integer(rule: &CompiledRule, name: &str) -> Option<i64> {
36    match rule.parameters.get(name)? {
37        ParameterValue::Integer { value } => Some(*value),
38        _ => None,
39    }
40}
41
42fn finding(
43    rule: &CompiledRule,
44    object: &Object,
45    message: String,
46    evidence: Vec<Evidence>,
47) -> Finding {
48    Finding {
49        rule_id: rule.id.clone(),
50        object_id: object.id.clone(),
51        severity: match rule.severity {
52            axioval_ir::contract::Severity::Error => Severity::Error,
53            axioval_ir::contract::Severity::Warning => Severity::Warning,
54            axioval_ir::contract::Severity::Info => Severity::Info,
55        },
56        message,
57        evidence,
58    }
59}
60
61fn unavailable_selected(
62    selected: &[&Object],
63    reason: &NotEvaluatedReason,
64    message: &str,
65    mut evaluation: CapabilityEvaluation,
66) -> CapabilityEvaluation {
67    for object in selected {
68        evaluation.push_object_not_evaluated(object.id.clone(), reason.clone(), message);
69    }
70    evaluation
71}
72
73fn resolve_error(
74    evaluation: &mut CapabilityEvaluation,
75    object: &Object,
76    error: axioval_engine::PropertyResolutionError,
77) {
78    let (reason, message) = property_error(error);
79    evaluation.push_object_not_evaluated(object.id.clone(), reason, message);
80}
81
82/// Requires an exactly resolved property to be present.
83pub struct PropertyExists;
84impl RuleCapability for PropertyExists {
85    fn id(&self) -> &'static str {
86        "axioval:capability.property-exists"
87    }
88
89    fn parameters(&self) -> Vec<ParameterDescriptor> {
90        vec![ParameterDescriptor::required(
91            "property",
92            ParameterType::PropertyReference,
93        )]
94    }
95
96    fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation {
97        let Some((set, name)) = property_reference(rule, "property") else {
98            return CapabilityEvaluation::not_evaluated(
99                NotEvaluatedReason::InvalidDeclaration,
100                "property-exists has no valid property reference",
101            );
102        };
103        let (selected, mut evaluation) = select_objects(context, &rule.selector);
104        let Some(service) = context.services.get::<PropertyResolutionServiceHandle>() else {
105            return unavailable_selected(
106                &selected,
107                &NotEvaluatedReason::MissingService,
108                "property-resolution service is not registered",
109                evaluation,
110            );
111        };
112        for object in selected {
113            let request =
114                match PropertyRequest::try_new(object.id.clone(), set.map(ToOwned::to_owned), name)
115                {
116                    Ok(request) => request,
117                    Err(error) => {
118                        evaluation.push_not_evaluated(
119                            NotEvaluatedReason::InvalidDeclaration,
120                            error.to_string(),
121                        );
122                        return evaluation;
123                    }
124                };
125            match service.resolve(&request) {
126                Ok(PropertyResolution::Present(_)) => {}
127                Ok(PropertyResolution::Absent(proof)) => evaluation.push_finding(finding(
128                    rule,
129                    object,
130                    format!("missing exact property {name}"),
131                    vec![proof.evidence().clone()],
132                )),
133                Err(error) => resolve_error(&mut evaluation, object, error),
134            }
135        }
136        evaluation
137    }
138}
139
140/// Requires an exactly resolved property to contain a non-empty semantic value.
141///
142/// Exact absence, `null`, and blank text are violations. Other exact typed
143/// values satisfy the requirement; adapter failures remain not-evaluated.
144pub struct PropertyRequired;
145impl RuleCapability for PropertyRequired {
146    fn id(&self) -> &'static str {
147        "axioval:capability.property-required"
148    }
149
150    fn parameters(&self) -> Vec<ParameterDescriptor> {
151        vec![ParameterDescriptor::required(
152            "property",
153            ParameterType::PropertyReference,
154        )]
155    }
156
157    fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation {
158        let Some((set, name)) = property_reference(rule, "property") else {
159            return CapabilityEvaluation::not_evaluated(
160                NotEvaluatedReason::InvalidDeclaration,
161                "property-required has no valid property reference",
162            );
163        };
164        let (selected, mut evaluation) = select_objects(context, &rule.selector);
165        let Some(service) = context.services.get::<PropertyResolutionServiceHandle>() else {
166            return unavailable_selected(
167                &selected,
168                &NotEvaluatedReason::MissingService,
169                "property-resolution service is not registered",
170                evaluation,
171            );
172        };
173        for object in selected {
174            let request =
175                match PropertyRequest::try_new(object.id.clone(), set.map(ToOwned::to_owned), name)
176                {
177                    Ok(request) => request,
178                    Err(error) => {
179                        evaluation.push_not_evaluated(
180                            NotEvaluatedReason::InvalidDeclaration,
181                            error.to_string(),
182                        );
183                        return evaluation;
184                    }
185                };
186            match service.resolve(&request) {
187                Ok(PropertyResolution::Present(resolved)) => {
188                    let value = &resolved.property().value;
189                    if matches!(value, PropertyValue::Null)
190                        || matches!(value, PropertyValue::String(text) if text.trim().is_empty())
191                    {
192                        evaluation.push_finding(finding(
193                            rule,
194                            object,
195                            format!("missing required property {name}"),
196                            resolved.property().evidence.clone().into_iter().collect(),
197                        ));
198                    }
199                }
200                Ok(PropertyResolution::Absent(proof)) => evaluation.push_finding(finding(
201                    rule,
202                    object,
203                    format!("missing required property {name}"),
204                    vec![proof.evidence().clone()],
205                )),
206                Err(error) => resolve_error(&mut evaluation, object, error),
207            }
208        }
209        evaluation
210    }
211}
212
213fn boolean(rule: &CompiledRule, name: &str) -> Option<bool> {
214    match rule.parameters.get(name)? {
215        ParameterValue::Boolean { value } => Some(*value),
216        _ => None,
217    }
218}
219
220/// Compares an exactly resolved boolean property with a declarative value.
221pub struct BooleanPropertyEquals;
222impl RuleCapability for BooleanPropertyEquals {
223    fn id(&self) -> &'static str {
224        "axioval:capability.property-value-equals"
225    }
226
227    fn parameters(&self) -> Vec<ParameterDescriptor> {
228        vec![
229            ParameterDescriptor::required("property", ParameterType::PropertyReference),
230            ParameterDescriptor::required("expected", ParameterType::Boolean),
231        ]
232    }
233
234    fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation {
235        let (Some((set, name)), Some(expected)) = (
236            property_reference(rule, "property"),
237            boolean(rule, "expected"),
238        ) else {
239            return CapabilityEvaluation::not_evaluated(
240                NotEvaluatedReason::InvalidDeclaration,
241                "property-value-equals parameters are invalid",
242            );
243        };
244        let (selected, mut evaluation) = select_objects(context, &rule.selector);
245        let Some(service) = context.services.get::<PropertyResolutionServiceHandle>() else {
246            return unavailable_selected(
247                &selected,
248                &NotEvaluatedReason::MissingService,
249                "property-resolution service is not registered",
250                evaluation,
251            );
252        };
253        for object in selected {
254            let request =
255                match PropertyRequest::try_new(object.id.clone(), set.map(ToOwned::to_owned), name)
256                {
257                    Ok(request) => request,
258                    Err(error) => {
259                        evaluation.push_not_evaluated(
260                            NotEvaluatedReason::InvalidDeclaration,
261                            error.to_string(),
262                        );
263                        return evaluation;
264                    }
265                };
266            match service.resolve(&request) {
267                Ok(PropertyResolution::Present(resolved)) => {
268                    let property = resolved.property();
269                    match &property.value {
270                        PropertyValue::Boolean(actual) if *actual == expected => {}
271                        PropertyValue::Boolean(_) => evaluation.push_finding(finding(
272                            rule,
273                            object,
274                            format!("property {name} does not equal {expected}"),
275                            property.evidence.clone().into_iter().collect(),
276                        )),
277                        _ => evaluation.push_object_not_evaluated(
278                            object.id.clone(),
279                            NotEvaluatedReason::InvalidEvidence,
280                            format!("property {name} is not boolean"),
281                        ),
282                    }
283                }
284                Ok(PropertyResolution::Absent(proof)) => evaluation.push_finding(finding(
285                    rule,
286                    object,
287                    format!("property {name} does not equal {expected}"),
288                    vec![proof.evidence().clone()],
289                )),
290                Err(error) => resolve_error(&mut evaluation, object, error),
291            }
292        }
293        evaluation
294    }
295}
296
297#[derive(Clone, Copy)]
298enum IntegerOperator {
299    Equal,
300    NotEqual,
301    GreaterThan,
302    GreaterOrEqual,
303    LessThan,
304    LessOrEqual,
305}
306impl IntegerOperator {
307    fn parse(value: &str) -> Option<Self> {
308        match value {
309            "equal" => Some(Self::Equal),
310            "not_equal" => Some(Self::NotEqual),
311            "greater_than" => Some(Self::GreaterThan),
312            "greater_or_equal" => Some(Self::GreaterOrEqual),
313            "less_than" => Some(Self::LessThan),
314            "less_or_equal" => Some(Self::LessOrEqual),
315            _ => None,
316        }
317    }
318    fn passes(self, actual: i64, expected: i64) -> bool {
319        match self {
320            Self::Equal => actual == expected,
321            Self::NotEqual => actual != expected,
322            Self::GreaterThan => actual > expected,
323            Self::GreaterOrEqual => actual >= expected,
324            Self::LessThan => actual < expected,
325            Self::LessOrEqual => actual <= expected,
326        }
327    }
328}
329
330/// Compares an exact integer property with a declarative integer literal.
331pub struct PropertyPredicate;
332impl RuleCapability for PropertyPredicate {
333    fn id(&self) -> &'static str {
334        "axioval:capability.property-predicate"
335    }
336
337    fn parameters(&self) -> Vec<ParameterDescriptor> {
338        vec![
339            ParameterDescriptor::required("property_set", ParameterType::String),
340            ParameterDescriptor::required("property", ParameterType::String),
341            ParameterDescriptor::required("operator", ParameterType::String),
342            ParameterDescriptor::required("value", ParameterType::Integer),
343        ]
344    }
345
346    fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation {
347        let (Some(set), Some(name), Some(operator), Some(expected)) = (
348            string(rule, "property_set"),
349            string(rule, "property"),
350            string(rule, "operator"),
351            integer(rule, "value"),
352        ) else {
353            return CapabilityEvaluation::not_evaluated(
354                NotEvaluatedReason::InvalidDeclaration,
355                "property-predicate parameters are invalid",
356            );
357        };
358        let Some(operator) = IntegerOperator::parse(operator) else {
359            return CapabilityEvaluation::not_evaluated(
360                NotEvaluatedReason::InvalidDeclaration,
361                "property-predicate operator is unsupported",
362            );
363        };
364        let (selected, mut evaluation) = select_objects(context, &rule.selector);
365        let Some(service) = context.services.get::<PropertyResolutionServiceHandle>() else {
366            return unavailable_selected(
367                &selected,
368                &NotEvaluatedReason::MissingService,
369                "property-resolution service is not registered",
370                evaluation,
371            );
372        };
373        for object in selected {
374            let request =
375                match PropertyRequest::try_new(object.id.clone(), Some(set.to_owned()), name) {
376                    Ok(request) => request,
377                    Err(error) => {
378                        evaluation.push_not_evaluated(
379                            NotEvaluatedReason::InvalidDeclaration,
380                            error.to_string(),
381                        );
382                        return evaluation;
383                    }
384                };
385            match service.resolve(&request) {
386                Ok(PropertyResolution::Present(resolved)) => {
387                    let property = resolved.property();
388                    let actual = match &property.value {
389                        PropertyValue::Integer(value) => Some(*value),
390                        _ => None,
391                    };
392                    if !actual.is_some_and(|actual| operator.passes(actual, expected)) {
393                        evaluation.push_finding(finding(
394                            rule,
395                            object,
396                            format!(
397                                "property {set}.{name} does not satisfy {} {expected}",
398                                string(rule, "operator").unwrap_or("invalid")
399                            ),
400                            property.evidence.clone().into_iter().collect(),
401                        ));
402                    }
403                }
404                Ok(PropertyResolution::Absent(proof)) => evaluation.push_finding(finding(
405                    rule,
406                    object,
407                    format!(
408                        "property {set}.{name} does not satisfy {} {expected}",
409                        string(rule, "operator").unwrap_or("invalid")
410                    ),
411                    vec![proof.evidence().clone()],
412                )),
413                Err(error) => resolve_error(&mut evaluation, object, error),
414            }
415        }
416        evaluation
417    }
418}