Skip to main content

tf_types/
capability.rs

1//! Capability semantics — mirrors `tools/tf-types-ts/src/core/capability.ts`.
2//!
3//! Evaluates constraint sets against a runtime context and computes the
4//! tighter intersection of two constraint sets. Unknown constraint variants
5//! fail closed.
6
7use crate::generated::common::{ApprovalRequirement, Constraint};
8
9#[derive(Clone, Debug, Default, PartialEq, Eq)]
10pub struct EvalContext {
11    pub now: String,
12    pub session_id: Option<String>,
13    pub target: Option<String>,
14    pub approver_count: Option<u32>,
15    pub device_actor: Option<String>,
16}
17
18pub fn constraints_satisfied(constraints: &[Constraint], ctx: &EvalContext) -> bool {
19    constraints.iter().all(|c| satisfies(c, ctx))
20}
21
22fn satisfies(c: &Constraint, ctx: &EvalContext) -> bool {
23    match c {
24        Constraint::TimeWindow { from, until } => {
25            if let Some(from_ts) = from {
26                if ctx.now.as_str() < from_ts.as_str() {
27                    return false;
28                }
29            }
30            ctx.now.as_str() <= until.as_str()
31        }
32        Constraint::Target { patterns } => match &ctx.target {
33            Some(t) => patterns.iter().any(|p| matches_glob(p, t)),
34            None => false,
35        },
36        Constraint::Quantity { .. } => true, // requires external counter
37        Constraint::Rate { .. } => true,     // requires external counter
38        Constraint::Session { session_id } => {
39            ctx.session_id.as_deref() == Some(session_id.as_str())
40        }
41        Constraint::Approval { approval } => matches!(
42            approval,
43            ApprovalRequirement::None | ApprovalRequirement::Conditional
44        ),
45        Constraint::Quorum { quorum, .. } => ctx.approver_count.unwrap_or(0) as i64 >= *quorum,
46        Constraint::DeviceBinding { device_actor } => {
47            ctx.device_actor.as_deref() == Some(device_actor.as_str())
48        }
49    }
50}
51
52pub fn intersect_constraints(a: &[Constraint], b: &[Constraint]) -> Vec<Constraint> {
53    let mut out: Vec<Constraint> = a.to_vec();
54    for nc in b {
55        let idx = out.iter().position(|c| same_kind(c, nc));
56        match idx {
57            Some(i) => {
58                out[i] = intersect_same(&out[i], nc);
59            }
60            None => out.push(nc.clone()),
61        }
62    }
63    out
64}
65
66fn same_kind(a: &Constraint, b: &Constraint) -> bool {
67    matches!(
68        (a, b),
69        (Constraint::TimeWindow { .. }, Constraint::TimeWindow { .. })
70            | (Constraint::Target { .. }, Constraint::Target { .. })
71            | (Constraint::Quantity { .. }, Constraint::Quantity { .. })
72            | (Constraint::Rate { .. }, Constraint::Rate { .. })
73            | (Constraint::Session { .. }, Constraint::Session { .. })
74            | (Constraint::Approval { .. }, Constraint::Approval { .. })
75            | (Constraint::Quorum { .. }, Constraint::Quorum { .. })
76            | (
77                Constraint::DeviceBinding { .. },
78                Constraint::DeviceBinding { .. }
79            )
80    )
81}
82
83fn intersect_same(a: &Constraint, b: &Constraint) -> Constraint {
84    match (a, b) {
85        (
86            Constraint::TimeWindow {
87                from: af,
88                until: au,
89            },
90            Constraint::TimeWindow {
91                from: bf,
92                until: bu,
93            },
94        ) => Constraint::TimeWindow {
95            from: pick_later(af.as_deref(), bf.as_deref()),
96            until: pick_earlier(au, bu).to_string(),
97        },
98        (Constraint::Target { patterns: ap }, Constraint::Target { patterns: bp }) => {
99            let shared: Vec<String> = ap.iter().filter(|p| bp.contains(p)).cloned().collect();
100            if shared.is_empty() {
101                let mut merged = ap.clone();
102                merged.extend(bp.iter().cloned());
103                Constraint::Target { patterns: merged }
104            } else {
105                Constraint::Target { patterns: shared }
106            }
107        }
108        (
109            Constraint::Quantity { max: am, unit: au },
110            Constraint::Quantity { max: bm, unit: bu },
111        ) => Constraint::Quantity {
112            max: (*am).min(*bm),
113            unit: au.clone().or_else(|| bu.clone()),
114        },
115        (
116            Constraint::Rate {
117                max_per_window: am,
118                window_seconds: aw,
119            },
120            Constraint::Rate {
121                max_per_window: bm,
122                window_seconds: bw,
123            },
124        ) => Constraint::Rate {
125            max_per_window: (*am).min(*bm),
126            window_seconds: (*aw).min(*bw),
127        },
128        (Constraint::Quorum { quorum: aq, of: ao }, Constraint::Quorum { quorum: bq, .. }) => {
129            Constraint::Quorum {
130                quorum: (*aq).max(*bq),
131                of: ao.clone(),
132            }
133        }
134        _ => a.clone(),
135    }
136}
137
138fn pick_later(a: Option<&str>, b: Option<&str>) -> Option<String> {
139    match (a, b) {
140        (None, None) => None,
141        (Some(x), None) => Some(x.to_string()),
142        (None, Some(y)) => Some(y.to_string()),
143        (Some(x), Some(y)) => Some(if x > y { x.to_string() } else { y.to_string() }),
144    }
145}
146
147fn pick_earlier<'a>(a: &'a str, b: &'a str) -> &'a str {
148    if a < b {
149        a
150    } else {
151        b
152    }
153}
154
155fn matches_glob(pattern: &str, value: &str) -> bool {
156    crate::glob::glob_match(pattern, value)
157}