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_json::Value =
221            crate::yaml::parse(text).map_err(|e| BridgesRegistryError::Parse(format!("{e}")))?;
222        let doc = match raw {
223            serde_json::Value::Object(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_json::Value> = None;
233        for (key, v) in doc {
234            match key.as_str() {
235                "registry_version" => {
236                    let s = v.as_str().ok_or_else(|| {
237                        BridgesRegistryError::Invalid("registry_version must be a string".into())
238                    })?;
239                    registry_version = Some(s.to_string());
240                }
241                "default_profile" => {
242                    if let serde_json::Value::Null = v {
243                        continue;
244                    }
245                    let s = v.as_str().ok_or_else(|| {
246                        BridgesRegistryError::Invalid("default_profile must be a string".into())
247                    })?;
248                    if !validate_profile(s) {
249                        return Err(BridgesRegistryError::Invalid(format!(
250                            "default_profile must match ^tf-[a-z][a-z0-9-]*-compatible$, got {s}"
251                        )));
252                    }
253                    default_profile = Some(s.to_string());
254                }
255                "bridges" => {
256                    bridges_value = Some(v);
257                }
258                other => {
259                    return Err(BridgesRegistryError::Invalid(format!(
260                        "unknown registry key: {other}"
261                    )));
262                }
263            }
264        }
265        let registry_version = registry_version
266            .ok_or_else(|| BridgesRegistryError::Invalid("registry_version is required".into()))?;
267        if registry_version != "1" {
268            return Err(BridgesRegistryError::Invalid(format!(
269                "registry_version must be \"1\", got {registry_version:?}"
270            )));
271        }
272        let bridges_value = bridges_value
273            .ok_or_else(|| BridgesRegistryError::Invalid("bridges is required".into()))?;
274        let entries = match bridges_value {
275            serde_json::Value::Array(s) => s,
276            _ => {
277                return Err(BridgesRegistryError::Invalid(
278                    "bridges must be a sequence".into(),
279                ))
280            }
281        };
282        let mut bridges = Vec::with_capacity(entries.len());
283        for (i, entry) in entries.into_iter().enumerate() {
284            bridges.push(parse_entry(entry, i)?);
285        }
286        Ok(BridgesRegistry {
287            registry_version: "1".into(),
288            default_profile,
289            bridges,
290        })
291    }
292
293    /// Resolve an incoming credential's issuer to a bridge entry.
294    /// Match precedence:
295    ///   1. exact `issuer_match` equality.
296    ///   2. `iss_pattern` substring match.
297    /// Returns `None` when nothing matches.
298    pub fn resolve_by_issuer(&self, iss: &str) -> Option<&BridgeEntry> {
299        if iss.is_empty() {
300            return None;
301        }
302        for entry in &self.bridges {
303            if let Some(m) = &entry.issuer_match {
304                if m == iss {
305                    return Some(entry);
306                }
307            }
308        }
309        for entry in &self.bridges {
310            if let Some(p) = &entry.iss_pattern {
311                if iss.contains(p.as_str()) {
312                    return Some(entry);
313                }
314            }
315        }
316        None
317    }
318
319    /// Resolve by bridge kind — returns the first matching entry.
320    pub fn resolve_by_kind(&self, kind: &BridgesRegistryKind) -> Option<&BridgeEntry> {
321        self.bridges.iter().find(|e| &e.kind == kind)
322    }
323}
324
325fn parse_entry(
326    value: serde_json::Value,
327    index: usize,
328) -> Result<BridgeEntry, BridgesRegistryError> {
329    let map = match value {
330        serde_json::Value::Object(m) => m,
331        _ => {
332            return Err(BridgesRegistryError::Invalid(format!(
333                "bridges[{index}] must be a mapping"
334            )))
335        }
336    };
337    let mut kind: Option<BridgesRegistryKind> = None;
338    let mut issuer_match: Option<String> = None;
339    let mut iss_pattern: Option<String> = None;
340    let mut trust_domain: Option<String> = None;
341    let mut trust_level: Option<String> = None;
342    let mut capability_map: Option<BTreeMap<String, String>> = None;
343    let mut profile: Option<String> = None;
344    for (key, v) in map {
345        match key.as_str() {
346            "kind" => {
347                let s = v.as_str().ok_or_else(|| {
348                    BridgesRegistryError::Invalid(format!("bridges[{index}].kind must be a string"))
349                })?;
350                kind = Some(BridgesRegistryKind::parse(s).ok_or_else(|| {
351                    BridgesRegistryError::Invalid(format!("bridges[{index}].kind invalid: {s}"))
352                })?);
353            }
354            "issuer_match" => {
355                if let serde_json::Value::Null = v {
356                    continue;
357                }
358                let s = v.as_str().ok_or_else(|| {
359                    BridgesRegistryError::Invalid(format!(
360                        "bridges[{index}].issuer_match must be a string"
361                    ))
362                })?;
363                if s.is_empty() {
364                    return Err(BridgesRegistryError::Invalid(format!(
365                        "bridges[{index}].issuer_match must be non-empty"
366                    )));
367                }
368                issuer_match = Some(s.to_string());
369            }
370            "iss_pattern" => {
371                if let serde_json::Value::Null = v {
372                    continue;
373                }
374                let s = v.as_str().ok_or_else(|| {
375                    BridgesRegistryError::Invalid(format!(
376                        "bridges[{index}].iss_pattern must be a string"
377                    ))
378                })?;
379                if s.is_empty() {
380                    return Err(BridgesRegistryError::Invalid(format!(
381                        "bridges[{index}].iss_pattern must be non-empty"
382                    )));
383                }
384                iss_pattern = Some(s.to_string());
385            }
386            "trust_domain" => {
387                if let serde_json::Value::Null = v {
388                    continue;
389                }
390                let s = v.as_str().ok_or_else(|| {
391                    BridgesRegistryError::Invalid(format!(
392                        "bridges[{index}].trust_domain must be a string"
393                    ))
394                })?;
395                trust_domain = Some(s.to_string());
396            }
397            "trust_level" => {
398                if let serde_json::Value::Null = v {
399                    continue;
400                }
401                let s = v.as_str().ok_or_else(|| {
402                    BridgesRegistryError::Invalid(format!(
403                        "bridges[{index}].trust_level must be a string"
404                    ))
405                })?;
406                if !TRUST_LEVELS.contains(&s) {
407                    return Err(BridgesRegistryError::Invalid(format!(
408                        "bridges[{index}].trust_level must be T0..T7"
409                    )));
410                }
411                trust_level = Some(s.to_string());
412            }
413            "capability_map" => {
414                if let serde_json::Value::Null = v {
415                    continue;
416                }
417                let m = match v {
418                    serde_json::Value::Object(m) => m,
419                    _ => {
420                        return Err(BridgesRegistryError::Invalid(format!(
421                            "bridges[{index}].capability_map must be a mapping"
422                        )))
423                    }
424                };
425                let mut out = BTreeMap::new();
426                for (mk, mv) in m {
427                    let mk = mk.as_str();
428                    let mv = mv.as_str().ok_or_else(|| {
429                        BridgesRegistryError::Invalid(format!(
430                            "bridges[{index}].capability_map[{mk}] must be a string"
431                        ))
432                    })?;
433                    if !validate_action_name(mv) {
434                        return Err(BridgesRegistryError::Invalid(format!(
435                            "bridges[{index}].capability_map[{mk}] must be a dotted action name"
436                        )));
437                    }
438                    out.insert(mk.to_string(), mv.to_string());
439                }
440                capability_map = Some(out);
441            }
442            "profile" => {
443                if let serde_json::Value::Null = v {
444                    continue;
445                }
446                let s = v.as_str().ok_or_else(|| {
447                    BridgesRegistryError::Invalid(format!(
448                        "bridges[{index}].profile must be a string"
449                    ))
450                })?;
451                if !validate_profile(s) {
452                    return Err(BridgesRegistryError::Invalid(format!(
453                        "bridges[{index}].profile must match ^tf-[a-z][a-z0-9-]*-compatible$"
454                    )));
455                }
456                profile = Some(s.to_string());
457            }
458            other => {
459                return Err(BridgesRegistryError::Invalid(format!(
460                    "bridges[{index}]: unknown key {other}"
461                )));
462            }
463        }
464    }
465    let kind = kind.ok_or_else(|| {
466        BridgesRegistryError::Invalid(format!("bridges[{index}].kind is required"))
467    })?;
468    Ok(BridgeEntry {
469        kind,
470        issuer_match,
471        iss_pattern,
472        trust_domain,
473        trust_level,
474        capability_map,
475        profile,
476    })
477}