1#![allow(clippy::should_implement_trait)]
2#![allow(clippy::doc_lazy_continuation)]
3use 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 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 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 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 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 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 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}