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    // Minimal glob: `*` matches non-`/` chars, `**` matches any.
157    // Escape regex meta first, then translate globs.
158    let mut re = String::with_capacity(pattern.len() + 4);
159    re.push('^');
160    let bytes = pattern.as_bytes();
161    let mut i = 0;
162    while i < bytes.len() {
163        let b = bytes[i];
164        match b {
165            b'*' => {
166                if i + 1 < bytes.len() && bytes[i + 1] == b'*' {
167                    re.push_str(".*");
168                    i += 2;
169                } else {
170                    re.push_str("[^/]*");
171                    i += 1;
172                }
173            }
174            b'.' | b'+' | b'^' | b'$' | b'{' | b'}' | b'(' | b')' | b'|' | b'[' | b']' | b'\\' => {
175                re.push('\\');
176                re.push(b as char);
177                i += 1;
178            }
179            _ => {
180                re.push(b as char);
181                i += 1;
182            }
183        }
184    }
185    re.push('$');
186    regex::Regex::new(&re)
187        .map(|r| r.is_match(value))
188        .unwrap_or(false)
189}