Skip to main content

tf_types/
bridges_registry.rs

1#![allow(clippy::should_implement_trait)]
2#![allow(clippy::doc_lazy_continuation)]
3//! Rust mirror of `tools/tf-types-ts/src/core/bridges-registry.ts`.
4//!
5//! Loads + validates `.tf/bridges.yaml` against
6//! `schemas/bridges-registry.schema.json` and exposes
7//! `resolve_by_issuer` so the daemon (or any host that wants to re-use
8//! the same logic in Rust) can map an incoming credential's `iss`
9//! claim / SPIFFE trust-domain / Clerk publishable-key prefix to a
10//! `BridgeEntry`.
11//!
12//! When the file is missing the registry is empty — the
13//! credential-resolver's built-in defaults cover that case. When the
14//! file is malformed `from_str` returns `BridgesRegistryError::Invalid`
15//! and the daemon refuses to start.
16
17use std::collections::BTreeMap;
18use std::fs;
19use std::path::Path;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum BridgesRegistryKind {
23    Oauth,
24    Clerk,
25    NextAuth,
26    BetterAuth,
27    Webauthn,
28    Tls,
29    Spiffe,
30    Did,
31    Gnap,
32    Mcp,
33    Matrix,
34    Webhook,
35    Grpc,
36    ServiceMesh,
37    A2a,
38    SessionCookie,
39    Aws,
40    Gcp,
41    Azure,
42    Vault,
43    Doppler,
44}
45
46impl BridgesRegistryKind {
47    pub fn parse(s: &str) -> Option<Self> {
48        Some(match s {
49            "oauth" => Self::Oauth,
50            "clerk" => Self::Clerk,
51            "next-auth" => Self::NextAuth,
52            "better-auth" => Self::BetterAuth,
53            "webauthn" => Self::Webauthn,
54            "tls" => Self::Tls,
55            "spiffe" => Self::Spiffe,
56            "did" => Self::Did,
57            "gnap" => Self::Gnap,
58            "mcp" => Self::Mcp,
59            "matrix" => Self::Matrix,
60            "webhook" => Self::Webhook,
61            "grpc" => Self::Grpc,
62            "service-mesh" => Self::ServiceMesh,
63            "a2a" => Self::A2a,
64            "session-cookie" => Self::SessionCookie,
65            "aws" => Self::Aws,
66            "gcp" => Self::Gcp,
67            "azure" => Self::Azure,
68            "vault" => Self::Vault,
69            "doppler" => Self::Doppler,
70            _ => return None,
71        })
72    }
73
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Self::Oauth => "oauth",
77            Self::Clerk => "clerk",
78            Self::NextAuth => "next-auth",
79            Self::BetterAuth => "better-auth",
80            Self::Webauthn => "webauthn",
81            Self::Tls => "tls",
82            Self::Spiffe => "spiffe",
83            Self::Did => "did",
84            Self::Gnap => "gnap",
85            Self::Mcp => "mcp",
86            Self::Matrix => "matrix",
87            Self::Webhook => "webhook",
88            Self::Grpc => "grpc",
89            Self::ServiceMesh => "service-mesh",
90            Self::A2a => "a2a",
91            Self::SessionCookie => "session-cookie",
92            Self::Aws => "aws",
93            Self::Gcp => "gcp",
94            Self::Azure => "azure",
95            Self::Vault => "vault",
96            Self::Doppler => "doppler",
97        }
98    }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct BridgeEntry {
103    pub kind: BridgesRegistryKind,
104    pub issuer_match: Option<String>,
105    pub iss_pattern: Option<String>,
106    pub trust_domain: Option<String>,
107    pub trust_level: Option<String>,
108    pub capability_map: Option<BTreeMap<String, String>>,
109    pub profile: Option<String>,
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Eq)]
113pub struct BridgesRegistry {
114    pub registry_version: String,
115    pub default_profile: Option<String>,
116    pub bridges: Vec<BridgeEntry>,
117}
118
119#[derive(Debug, thiserror::Error)]
120pub enum BridgesRegistryError {
121    #[error("invalid registry: {0}")]
122    Invalid(String),
123    #[error("io: {0}")]
124    Io(#[from] std::io::Error),
125    #[error("parse: {0}")]
126    Parse(String),
127}
128
129const TRUST_LEVELS: &[&str] = &["T0", "T1", "T2", "T3", "T4", "T5", "T6", "T7"];
130
131fn validate_profile(s: &str) -> bool {
132    // ^tf-[a-z][a-z0-9-]*-compatible$
133    let mut chars = s.chars();
134    if chars.next() != Some('t') || chars.next() != Some('f') || chars.next() != Some('-') {
135        return false;
136    }
137    let body: String = chars.collect();
138    if !body.ends_with("-compatible") {
139        return false;
140    }
141    let middle = &body[..body.len() - "-compatible".len()];
142    if middle.is_empty() {
143        return false;
144    }
145    let mut it = middle.chars();
146    let first = match it.next() {
147        Some(c) => c,
148        None => return false,
149    };
150    if !first.is_ascii_lowercase() {
151        return false;
152    }
153    for c in it {
154        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
155            return false;
156        }
157    }
158    true
159}
160
161fn validate_action_name(s: &str) -> bool {
162    // ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$
163    let mut segs = s.split('.');
164    let first = match segs.next() {
165        Some(v) if !v.is_empty() => v,
166        _ => return false,
167    };
168    if !valid_action_segment(first) {
169        return false;
170    }
171    let mut count = 0;
172    for seg in segs {
173        if !valid_action_segment(seg) {
174            return false;
175        }
176        count += 1;
177    }
178    count >= 1
179}
180
181fn valid_action_segment(s: &str) -> bool {
182    let mut it = s.chars();
183    let first = match it.next() {
184        Some(c) => c,
185        None => return false,
186    };
187    if !first.is_ascii_lowercase() {
188        return false;
189    }
190    for c in it {
191        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
192            return false;
193        }
194    }
195    true
196}
197
198impl BridgesRegistry {
199    /// Load `.tf/bridges.yaml` from disk. A missing file resolves to
200    /// an empty registry — the resolver falls back to its built-in
201    /// defaults in that case.
202    pub fn load(path: impl AsRef<Path>) -> Result<Self, BridgesRegistryError> {
203        let path = path.as_ref();
204        let text = match fs::read_to_string(path) {
205            Ok(t) => t,
206            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
207                return Ok(BridgesRegistry {
208                    registry_version: "1".into(),
209                    default_profile: None,
210                    bridges: Vec::new(),
211                });
212            }
213            Err(e) => return Err(BridgesRegistryError::Io(e)),
214        };
215        Self::from_str(&text)
216    }
217
218    /// Parse + validate a YAML/JSON registry document from a string.
219    pub fn from_str(text: &str) -> Result<Self, BridgesRegistryError> {
220        let raw: serde_yaml::Value =
221            serde_yaml::from_str(text).map_err(|e| BridgesRegistryError::Parse(format!("{e}")))?;
222        let doc = match raw {
223            serde_yaml::Value::Mapping(m) => m,
224            _ => {
225                return Err(BridgesRegistryError::Invalid(
226                    "registry root must be a mapping".into(),
227                ))
228            }
229        };
230        let mut registry_version: Option<String> = None;
231        let mut default_profile: Option<String> = None;
232        let mut bridges_value: Option<serde_yaml::Value> = None;
233        for (k, v) in doc {
234            let key = k
235                .as_str()
236                .ok_or_else(|| BridgesRegistryError::Invalid("non-string key in registry".into()))?
237                .to_string();
238            match key.as_str() {
239                "registry_version" => {
240                    let s = v.as_str().ok_or_else(|| {
241                        BridgesRegistryError::Invalid("registry_version must be a string".into())
242                    })?;
243                    registry_version = Some(s.to_string());
244                }
245                "default_profile" => {
246                    if let serde_yaml::Value::Null = v {
247                        continue;
248                    }
249                    let s = v.as_str().ok_or_else(|| {
250                        BridgesRegistryError::Invalid("default_profile must be a string".into())
251                    })?;
252                    if !validate_profile(s) {
253                        return Err(BridgesRegistryError::Invalid(format!(
254                            "default_profile must match ^tf-[a-z][a-z0-9-]*-compatible$, got {s}"
255                        )));
256                    }
257                    default_profile = Some(s.to_string());
258                }
259                "bridges" => {
260                    bridges_value = Some(v);
261                }
262                other => {
263                    return Err(BridgesRegistryError::Invalid(format!(
264                        "unknown registry key: {other}"
265                    )));
266                }
267            }
268        }
269        let registry_version = registry_version
270            .ok_or_else(|| BridgesRegistryError::Invalid("registry_version is required".into()))?;
271        if registry_version != "1" {
272            return Err(BridgesRegistryError::Invalid(format!(
273                "registry_version must be \"1\", got {registry_version:?}"
274            )));
275        }
276        let bridges_value = bridges_value
277            .ok_or_else(|| BridgesRegistryError::Invalid("bridges is required".into()))?;
278        let entries = match bridges_value {
279            serde_yaml::Value::Sequence(s) => s,
280            _ => {
281                return Err(BridgesRegistryError::Invalid(
282                    "bridges must be a sequence".into(),
283                ))
284            }
285        };
286        let mut bridges = Vec::with_capacity(entries.len());
287        for (i, entry) in entries.into_iter().enumerate() {
288            bridges.push(parse_entry(entry, i)?);
289        }
290        Ok(BridgesRegistry {
291            registry_version: "1".into(),
292            default_profile,
293            bridges,
294        })
295    }
296
297    /// Resolve an incoming credential's issuer to a bridge entry.
298    /// Match precedence:
299    ///   1. exact `issuer_match` equality.
300    ///   2. `iss_pattern` substring match.
301    /// Returns `None` when nothing matches.
302    pub fn resolve_by_issuer(&self, iss: &str) -> Option<&BridgeEntry> {
303        if iss.is_empty() {
304            return None;
305        }
306        for entry in &self.bridges {
307            if let Some(m) = &entry.issuer_match {
308                if m == iss {
309                    return Some(entry);
310                }
311            }
312        }
313        for entry in &self.bridges {
314            if let Some(p) = &entry.iss_pattern {
315                if iss.contains(p.as_str()) {
316                    return Some(entry);
317                }
318            }
319        }
320        None
321    }
322
323    /// Resolve by bridge kind — returns the first matching entry.
324    pub fn resolve_by_kind(&self, kind: &BridgesRegistryKind) -> Option<&BridgeEntry> {
325        self.bridges.iter().find(|e| &e.kind == kind)
326    }
327}
328
329fn parse_entry(
330    value: serde_yaml::Value,
331    index: usize,
332) -> Result<BridgeEntry, BridgesRegistryError> {
333    let map = match value {
334        serde_yaml::Value::Mapping(m) => m,
335        _ => {
336            return Err(BridgesRegistryError::Invalid(format!(
337                "bridges[{index}] must be a mapping"
338            )))
339        }
340    };
341    let mut kind: Option<BridgesRegistryKind> = None;
342    let mut issuer_match: Option<String> = None;
343    let mut iss_pattern: Option<String> = None;
344    let mut trust_domain: Option<String> = None;
345    let mut trust_level: Option<String> = None;
346    let mut capability_map: Option<BTreeMap<String, String>> = None;
347    let mut profile: Option<String> = None;
348    for (k, v) in map {
349        let key = k
350            .as_str()
351            .ok_or_else(|| {
352                BridgesRegistryError::Invalid(format!("bridges[{index}] has non-string key"))
353            })?
354            .to_string();
355        match key.as_str() {
356            "kind" => {
357                let s = v.as_str().ok_or_else(|| {
358                    BridgesRegistryError::Invalid(format!("bridges[{index}].kind must be a string"))
359                })?;
360                kind = Some(BridgesRegistryKind::parse(s).ok_or_else(|| {
361                    BridgesRegistryError::Invalid(format!("bridges[{index}].kind invalid: {s}"))
362                })?);
363            }
364            "issuer_match" => {
365                if let serde_yaml::Value::Null = v {
366                    continue;
367                }
368                let s = v.as_str().ok_or_else(|| {
369                    BridgesRegistryError::Invalid(format!(
370                        "bridges[{index}].issuer_match must be a string"
371                    ))
372                })?;
373                if s.is_empty() {
374                    return Err(BridgesRegistryError::Invalid(format!(
375                        "bridges[{index}].issuer_match must be non-empty"
376                    )));
377                }
378                issuer_match = Some(s.to_string());
379            }
380            "iss_pattern" => {
381                if let serde_yaml::Value::Null = v {
382                    continue;
383                }
384                let s = v.as_str().ok_or_else(|| {
385                    BridgesRegistryError::Invalid(format!(
386                        "bridges[{index}].iss_pattern must be a string"
387                    ))
388                })?;
389                if s.is_empty() {
390                    return Err(BridgesRegistryError::Invalid(format!(
391                        "bridges[{index}].iss_pattern must be non-empty"
392                    )));
393                }
394                iss_pattern = Some(s.to_string());
395            }
396            "trust_domain" => {
397                if let serde_yaml::Value::Null = v {
398                    continue;
399                }
400                let s = v.as_str().ok_or_else(|| {
401                    BridgesRegistryError::Invalid(format!(
402                        "bridges[{index}].trust_domain must be a string"
403                    ))
404                })?;
405                trust_domain = Some(s.to_string());
406            }
407            "trust_level" => {
408                if let serde_yaml::Value::Null = v {
409                    continue;
410                }
411                let s = v.as_str().ok_or_else(|| {
412                    BridgesRegistryError::Invalid(format!(
413                        "bridges[{index}].trust_level must be a string"
414                    ))
415                })?;
416                if !TRUST_LEVELS.contains(&s) {
417                    return Err(BridgesRegistryError::Invalid(format!(
418                        "bridges[{index}].trust_level must be T0..T7"
419                    )));
420                }
421                trust_level = Some(s.to_string());
422            }
423            "capability_map" => {
424                if let serde_yaml::Value::Null = v {
425                    continue;
426                }
427                let m = match v {
428                    serde_yaml::Value::Mapping(m) => m,
429                    _ => {
430                        return Err(BridgesRegistryError::Invalid(format!(
431                            "bridges[{index}].capability_map must be a mapping"
432                        )))
433                    }
434                };
435                let mut out = BTreeMap::new();
436                for (mk, mv) in m {
437                    let mk = mk.as_str().ok_or_else(|| {
438                        BridgesRegistryError::Invalid(format!(
439                            "bridges[{index}].capability_map has non-string key"
440                        ))
441                    })?;
442                    let mv = mv.as_str().ok_or_else(|| {
443                        BridgesRegistryError::Invalid(format!(
444                            "bridges[{index}].capability_map[{mk}] must be a string"
445                        ))
446                    })?;
447                    if !validate_action_name(mv) {
448                        return Err(BridgesRegistryError::Invalid(format!(
449                            "bridges[{index}].capability_map[{mk}] must be a dotted action name"
450                        )));
451                    }
452                    out.insert(mk.to_string(), mv.to_string());
453                }
454                capability_map = Some(out);
455            }
456            "profile" => {
457                if let serde_yaml::Value::Null = v {
458                    continue;
459                }
460                let s = v.as_str().ok_or_else(|| {
461                    BridgesRegistryError::Invalid(format!(
462                        "bridges[{index}].profile must be a string"
463                    ))
464                })?;
465                if !validate_profile(s) {
466                    return Err(BridgesRegistryError::Invalid(format!(
467                        "bridges[{index}].profile must match ^tf-[a-z][a-z0-9-]*-compatible$"
468                    )));
469                }
470                profile = Some(s.to_string());
471            }
472            other => {
473                return Err(BridgesRegistryError::Invalid(format!(
474                    "bridges[{index}]: unknown key {other}"
475                )));
476            }
477        }
478    }
479    let kind = kind.ok_or_else(|| {
480        BridgesRegistryError::Invalid(format!("bridges[{index}].kind is required"))
481    })?;
482    Ok(BridgeEntry {
483        kind,
484        issuer_match,
485        iss_pattern,
486        trust_domain,
487        trust_level,
488        capability_map,
489        profile,
490    })
491}