Skip to main content

av_harness/
service_binding.rs

1//! Cloud-platform service binding discovery.
2//!
3//! Discovers AgentVisor connection parameters from Cloud Foundry
4//! [`VCAP_SERVICES`](https://docs.cloudfoundry.org/devguide/deploy-apps/environment-variable.html#VCAP-SERVICES)
5//! or from Kubernetes-style
6//! [service binding files](https://servicebinding.io/spec/core/1.0.0/).
7//!
8//! Activated by the `service-binding` cargo feature. Not compiled into
9//! production artifacts by default. This is client discovery metadata; the
10//! gateway does not use a binding to configure its own identity validator.
11
12use std::path::Path;
13
14/// Credentials extracted from a platform service binding.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ServiceBinding {
17    /// The AgentVisor gateway URL that the application should proxy
18    /// requests through.
19    pub gateway_url: String,
20    /// The OAuth audience the gateway expects on tokens.
21    pub audience: String,
22    /// An optional JWKS URL for identity verification.
23    pub identity_jwks_url: Option<String>,
24}
25
26/// Errors that can occur while reading a service binding.
27#[derive(Debug, thiserror::Error)]
28pub enum ServiceBindingError {
29    /// The `VCAP_SERVICES` value is not valid JSON.
30    #[error("VCAP_SERVICES is not valid JSON: {0}")]
31    InvalidJson(#[from] serde_json::Error),
32
33    /// JSON has an invalid platform binding structure.
34    #[error("invalid VCAP_SERVICES structure: {0}")]
35    InvalidStructure(&'static str),
36
37    /// A credential has an invalid type or contains an ASCII control character.
38    #[error("invalid binding field: {field}")]
39    InvalidField {
40        /// The name of the invalid field.
41        field: &'static str,
42    },
43
44    /// More than one matching service entry was found, creating
45    /// ambiguity about which binding to use.
46    #[error("ambiguous binding: found {count} matching agentvisor entries")]
47    Ambiguous {
48        /// How many matching entries were found.
49        count: usize,
50    },
51
52    /// A matched binding is missing a required credential field.
53    #[error("binding is missing required field: {field}")]
54    MissingField {
55        /// The name of the missing field.
56        field: &'static str,
57    },
58
59    /// A required credential field is present but empty after trimming.
60    #[error("binding field is empty: {field}")]
61    EmptyField {
62        /// The name of the empty field.
63        field: &'static str,
64    },
65
66    /// An I/O error occurred while reading binding files.
67    #[error("failed to read binding file: {0}")]
68    Io(#[from] std::io::Error),
69}
70
71/// The default directory where Kubernetes-style service bindings are
72/// mounted (Cloud Native Buildpacks convention).
73const DEFAULT_BINDING_ROOT: &str = "/platform/bindings";
74
75/// Tries to discover an AgentVisor service binding from the
76/// environment.
77///
78/// The function checks `VCAP_SERVICES` first. If that variable is not
79/// set, it falls back to file-based bindings under the directory named
80/// by `SERVICE_BINDING_ROOT` (defaulting to `/platform/bindings`).
81///
82/// Returns `Ok(None)` when no binding is found. Returns an error when
83/// a binding is present but malformed (missing or empty required
84/// fields, ambiguous matches, invalid JSON).
85pub fn try_from_env() -> Result<Option<ServiceBinding>, ServiceBindingError> {
86    try_from_env_with(|key| std::env::var(key))
87}
88
89/// Inner implementation that accepts a lookup closure so tests can
90/// supply synthetic environment variables without mutating global
91/// process state.
92fn try_from_env_with<F>(lookup: F) -> Result<Option<ServiceBinding>, ServiceBindingError>
93where
94    F: Fn(&str) -> Result<String, std::env::VarError>,
95{
96    // VCAP_SERVICES takes precedence. If a match is found (or an error
97    // occurs), return immediately. If no match is found (`Ok(None)`),
98    // fall through to file-based bindings. On CF, VCAP_SERVICES is
99    // always set (often to `{}`), so returning on every non-empty value
100    // would prevent file-based bindings from ever being discovered.
101    if let Ok(vcap) = lookup("VCAP_SERVICES") {
102        if !vcap.trim().is_empty() {
103            if let Some(binding) = from_vcap_services(&vcap)? {
104                return Ok(Some(binding));
105            }
106            // No match in VCAP — fall through to file-based bindings.
107        }
108    }
109
110    // Fall back to file-based bindings.
111    let root = lookup("SERVICE_BINDING_ROOT")
112        .ok()
113        .filter(|s| !s.trim().is_empty())
114        .unwrap_or_else(|| DEFAULT_BINDING_ROOT.to_string());
115
116    from_binding_root(Path::new(&root))
117}
118
119/// Parses `VCAP_SERVICES` JSON and extracts an AgentVisor binding.
120///
121/// A service entry matches if any of the following are true:
122/// - The top-level key equals `"agentvisor"` (managed service).
123/// - The entry's `"label"` equals `"agentvisor"`.
124/// - The entry's `"tags"` array contains `"agentvisor"`.
125/// - The entry's `"name"` equals `"agentvisor"`.
126///
127/// User-provided services (created with `cf cups`) appear under the
128/// `"user-provided"` top-level key, so matching by label, tag, or name
129/// is needed to find them.
130pub fn from_vcap_services(json: &str) -> Result<Option<ServiceBinding>, ServiceBindingError> {
131    let root: serde_json::Value = serde_json::from_str(json)?;
132    let obj = match root.as_object() {
133        Some(o) => o,
134        None => return Err(ServiceBindingError::InvalidStructure("root must be an object")),
135    };
136
137    let mut matches = Vec::new();
138
139    for (key, entries) in obj {
140        let arr = match entries.as_array() {
141            Some(a) => a,
142            None => {
143                return Err(ServiceBindingError::InvalidStructure(
144                    "service values must be arrays",
145                ));
146            }
147        };
148        let key_match = key == "agentvisor";
149
150        for entry in arr {
151            if !entry.is_object() {
152                return Err(ServiceBindingError::InvalidStructure(
153                    "service entries must be objects",
154                ));
155            }
156            if key_match || entry_matches(entry) {
157                matches.push(entry.clone());
158            }
159        }
160    }
161
162    match matches.len() {
163        0 => Ok(None),
164        1 => {
165            let entry = matches
166                .first()
167                .and_then(|e| e.get("credentials"))
168                .and_then(|c| c.as_object());
169            let creds = match entry {
170                Some(c) => c,
171                None => {
172                    // The entry matched but has no credentials object.
173                    return Err(ServiceBindingError::MissingField { field: "credentials" });
174                }
175            };
176            binding_from_creds(creds)
177        }
178        n => Err(ServiceBindingError::Ambiguous { count: n }),
179    }
180}
181
182/// Checks whether a single VCAP service entry matches by label, tag,
183/// or name.
184fn entry_matches(entry: &serde_json::Value) -> bool {
185    if entry
186        .get("label")
187        .and_then(serde_json::Value::as_str)
188        .is_some_and(|l| l == "agentvisor")
189    {
190        return true;
191    }
192    if entry
193        .get("name")
194        .and_then(serde_json::Value::as_str)
195        .is_some_and(|n| n == "agentvisor")
196    {
197        return true;
198    }
199    if let Some(tags) = entry.get("tags").and_then(serde_json::Value::as_array) {
200        if tags.iter().any(|t| t.as_str().is_some_and(|s| s == "agentvisor")) {
201            return true;
202        }
203    }
204    false
205}
206
207/// Extracts binding fields from a credentials object.
208fn binding_from_creds(
209    creds: &serde_json::Map<String, serde_json::Value>,
210) -> Result<Option<ServiceBinding>, ServiceBindingError> {
211    let gateway_url = required_string(creds, "gateway_url")?;
212    let audience = required_string(creds, "audience")?;
213    let identity_jwks_url = optional_string(creds, "identity_jwks_url")?;
214
215    Ok(Some(ServiceBinding {
216        gateway_url,
217        audience,
218        identity_jwks_url,
219    }))
220}
221
222/// Reads a required string field, returning an error if it is missing
223/// or empty after trimming.
224fn required_string(
225    creds: &serde_json::Map<String, serde_json::Value>,
226    field: &'static str,
227) -> Result<String, ServiceBindingError> {
228    let val = creds
229        .get(field)
230        .and_then(serde_json::Value::as_str)
231        .ok_or(ServiceBindingError::MissingField { field })?;
232
233    let trimmed = val.trim();
234    if trimmed.is_empty() {
235        return Err(ServiceBindingError::EmptyField { field });
236    }
237    validate_value(trimmed, field)?;
238    Ok(trimmed.to_string())
239}
240
241/// Reads an optional string field, returning `None` if it is absent or
242/// empty after trimming.
243fn optional_string(
244    creds: &serde_json::Map<String, serde_json::Value>,
245    field: &'static str,
246) -> Result<Option<String>, ServiceBindingError> {
247    match creds.get(field) {
248        None | Some(serde_json::Value::Null) => Ok(None),
249        Some(serde_json::Value::String(value)) => {
250            let value = value.trim();
251            validate_value(value, field)?;
252            Ok((!value.is_empty()).then(|| value.to_string()))
253        }
254        Some(_) => Err(ServiceBindingError::InvalidField { field }),
255    }
256}
257
258fn validate_value(value: &str, field: &'static str) -> Result<(), ServiceBindingError> {
259    if value.chars().any(|c| c.is_ascii_control()) {
260        return Err(ServiceBindingError::InvalidField { field });
261    }
262    Ok(())
263}
264
265/// Scans the binding root directory for a subdirectory whose `type`
266/// file contains `agentvisor`.
267///
268/// Binding directories that start with `.` are skipped. The root
269/// directory itself may be absent, which returns `Ok(None)`.
270pub fn from_binding_root(root: &Path) -> Result<Option<ServiceBinding>, ServiceBindingError> {
271    let entries = match std::fs::read_dir(root) {
272        Ok(e) => e,
273        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
274        Err(e) => return Err(e.into()),
275    };
276
277    let mut matching_dirs: Vec<std::path::PathBuf> = Vec::new();
278
279    for entry in entries {
280        let entry = entry?;
281        let name = entry.file_name();
282        let name_str = name.to_string_lossy();
283
284        // Skip hidden entries.
285        if name_str.starts_with('.') {
286            continue;
287        }
288
289        let binding_dir = entry.path();
290
291        // Follow symlinks: use `fs::metadata` instead of
292        // `entry.file_type()`, because Kubernetes projects bindings
293        // through symlinks.
294        let meta = match std::fs::metadata(&binding_dir) {
295            Ok(m) => m,
296            Err(_) => continue,
297        };
298        if !meta.is_dir() {
299            continue;
300        }
301
302        let type_path = binding_dir.join("type");
303        let type_val = match std::fs::read_to_string(&type_path) {
304            Ok(v) => v,
305            Err(_) => continue,
306        };
307
308        if type_val.trim() == "agentvisor" {
309            matching_dirs.push(binding_dir);
310        }
311    }
312
313    match matching_dirs.len() {
314        0 => Ok(None),
315        1 => {
316            // Safe: length was just checked.
317            let dir = matching_dirs
318                .first()
319                .ok_or(ServiceBindingError::MissingField { field: "type" })?;
320            binding_from_files(dir)
321        }
322        n => Err(ServiceBindingError::Ambiguous { count: n }),
323    }
324}
325
326/// Reads credential fields from individual files in a binding
327/// directory.
328fn binding_from_files(dir: &Path) -> Result<Option<ServiceBinding>, ServiceBindingError> {
329    let gateway_url = required_file(dir, "gateway_url")?;
330    let audience = required_file(dir, "audience")?;
331    let identity_jwks_url = optional_file(dir, "identity_jwks_url")?;
332
333    Ok(Some(ServiceBinding {
334        gateway_url,
335        audience,
336        identity_jwks_url,
337    }))
338}
339
340/// Reads a required credential from a file, returning an error if the
341/// file is missing or its trimmed content is empty.
342fn required_file(dir: &Path, field: &'static str) -> Result<String, ServiceBindingError> {
343    let path = dir.join(field);
344    let content = std::fs::read_to_string(&path).map_err(|_| ServiceBindingError::MissingField { field })?;
345    let trimmed = content.trim();
346    if trimmed.is_empty() {
347        return Err(ServiceBindingError::EmptyField { field });
348    }
349    validate_value(trimmed, field)?;
350    Ok(trimmed.to_string())
351}
352
353/// Reads an optional credential from a file. Returns `None` if the
354/// file is absent or its trimmed content is empty.
355fn optional_file(dir: &Path, field: &'static str) -> Result<Option<String>, ServiceBindingError> {
356    let path = dir.join(field);
357    match std::fs::read_to_string(&path) {
358        Ok(content) => {
359            let trimmed = content.trim();
360            validate_value(trimmed, field)?;
361            if trimmed.is_empty() {
362                Ok(None)
363            } else {
364                Ok(Some(trimmed.to_string()))
365            }
366        }
367        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
368        Err(e) => Err(e.into()),
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
375
376    use super::*;
377    use std::collections::HashMap;
378
379    /// Helper: build a lookup closure from a map.
380    fn lookup_from(
381        map: HashMap<&'static str, String>,
382    ) -> impl Fn(&str) -> Result<String, std::env::VarError> {
383        move |key: &str| map.get(key).cloned().ok_or(std::env::VarError::NotPresent)
384    }
385
386    // ---- VCAP_SERVICES tests ----
387
388    #[test]
389    fn vcap_managed_service_by_key() {
390        let vcap = r#"{
391            "agentvisor": [{
392                "credentials": {
393                    "gateway_url": "https://gw.example.com",
394                    "audience": "my-app",
395                    "identity_jwks_url": "https://auth.example.com/.well-known/jwks.json"
396                },
397                "label": "agentvisor",
398                "name": "my-agentvisor"
399            }]
400        }"#;
401
402        let result = from_vcap_services(vcap).unwrap().unwrap();
403        assert_eq!(result.gateway_url, "https://gw.example.com");
404        assert_eq!(result.audience, "my-app");
405        assert_eq!(
406            result.identity_jwks_url.as_deref(),
407            Some("https://auth.example.com/.well-known/jwks.json")
408        );
409    }
410
411    #[test]
412    fn vcap_user_provided_matched_by_tag() {
413        let vcap = r#"{
414            "user-provided": [{
415                "credentials": {
416                    "gateway_url": "https://gw.example.com",
417                    "audience": "tagged-app"
418                },
419                "label": "user-provided",
420                "name": "my-custom-svc",
421                "tags": ["agentvisor", "ai"]
422            }]
423        }"#;
424
425        let result = from_vcap_services(vcap).unwrap().unwrap();
426        assert_eq!(result.audience, "tagged-app");
427        assert!(result.identity_jwks_url.is_none());
428    }
429
430    #[test]
431    fn vcap_user_provided_matched_by_name() {
432        let vcap = r#"{
433            "user-provided": [{
434                "credentials": {
435                    "gateway_url": "https://gw.example.com",
436                    "audience": "named-app"
437                },
438                "label": "user-provided",
439                "name": "agentvisor"
440            }]
441        }"#;
442
443        let result = from_vcap_services(vcap).unwrap().unwrap();
444        assert_eq!(result.audience, "named-app");
445    }
446
447    #[test]
448    fn vcap_user_provided_matched_by_label() {
449        let vcap = r#"{
450            "user-provided": [{
451                "credentials": {
452                    "gateway_url": "https://gw.example.com",
453                    "audience": "labeled-app"
454                },
455                "label": "agentvisor",
456                "name": "something-else"
457            }]
458        }"#;
459
460        let result = from_vcap_services(vcap).unwrap().unwrap();
461        assert_eq!(result.audience, "labeled-app");
462    }
463
464    #[test]
465    fn vcap_no_match_returns_none() {
466        let vcap = r#"{
467            "postgres": [{
468                "credentials": { "uri": "postgres://..." },
469                "label": "postgres"
470            }]
471        }"#;
472
473        assert!(from_vcap_services(vcap).unwrap().is_none());
474    }
475
476    #[test]
477    fn vcap_invalid_json_is_error() {
478        let result = from_vcap_services("{not valid json}");
479        assert!(result.is_err());
480        let err = result.unwrap_err();
481        assert!(matches!(err, ServiceBindingError::InvalidJson(_)));
482    }
483
484    #[test]
485    fn vcap_ambiguous_entries_is_error() {
486        let vcap = r#"{
487            "agentvisor": [
488                {
489                    "credentials": { "gateway_url": "https://a.example.com", "audience": "a" },
490                    "name": "first"
491                },
492                {
493                    "credentials": { "gateway_url": "https://b.example.com", "audience": "b" },
494                    "name": "second"
495                }
496            ]
497        }"#;
498
499        let result = from_vcap_services(vcap);
500        assert!(result.is_err());
501        let err = result.unwrap_err();
502        assert!(matches!(err, ServiceBindingError::Ambiguous { count: 2 }));
503    }
504
505    #[test]
506    fn vcap_missing_required_field_is_error() {
507        let vcap = r#"{
508            "agentvisor": [{
509                "credentials": {
510                    "gateway_url": "https://gw.example.com"
511                },
512                "name": "incomplete"
513            }]
514        }"#;
515
516        let result = from_vcap_services(vcap);
517        assert!(result.is_err());
518        let err = result.unwrap_err();
519        assert!(matches!(
520            err,
521            ServiceBindingError::MissingField { field: "audience" }
522        ));
523    }
524
525    #[test]
526    fn vcap_empty_required_field_is_error() {
527        let vcap = r#"{
528            "agentvisor": [{
529                "credentials": {
530                    "gateway_url": "   ",
531                    "audience": "my-app"
532                },
533                "name": "empty-gw"
534            }]
535        }"#;
536
537        let result = from_vcap_services(vcap);
538        assert!(result.is_err());
539        let err = result.unwrap_err();
540        assert!(matches!(
541            err,
542            ServiceBindingError::EmptyField { field: "gateway_url" }
543        ));
544    }
545
546    #[test]
547    fn vcap_missing_credentials_is_error() {
548        let vcap = r#"{
549            "agentvisor": [{ "name": "no-creds" }]
550        }"#;
551
552        let result = from_vcap_services(vcap);
553        assert!(result.is_err());
554        let err = result.unwrap_err();
555        assert!(matches!(
556            err,
557            ServiceBindingError::MissingField { field: "credentials" }
558        ));
559    }
560
561    #[test]
562    fn vcap_optional_jwks_absent_is_none() {
563        let vcap = r#"{
564            "agentvisor": [{
565                "credentials": {
566                    "gateway_url": "https://gw.example.com",
567                    "audience": "my-app"
568                },
569                "name": "no-jwks"
570            }]
571        }"#;
572
573        let result = from_vcap_services(vcap).unwrap().unwrap();
574        assert!(result.identity_jwks_url.is_none());
575    }
576
577    // ---- File-based binding tests ----
578
579    #[test]
580    fn binding_files_full() {
581        let tmp = tempfile::tempdir().unwrap();
582        let binding = tmp.path().join("agentvisor");
583        std::fs::create_dir_all(&binding).unwrap();
584        std::fs::write(binding.join("type"), "agentvisor\n").unwrap();
585        std::fs::write(binding.join("gateway_url"), "https://gw.example.com\n").unwrap();
586        std::fs::write(binding.join("audience"), "file-app\n").unwrap();
587        std::fs::write(
588            binding.join("identity_jwks_url"),
589            "https://auth.example.com/jwks\n",
590        )
591        .unwrap();
592
593        let result = from_binding_root(tmp.path()).unwrap().unwrap();
594        assert_eq!(result.gateway_url, "https://gw.example.com");
595        assert_eq!(result.audience, "file-app");
596        assert_eq!(
597            result.identity_jwks_url.as_deref(),
598            Some("https://auth.example.com/jwks")
599        );
600    }
601
602    #[test]
603    fn binding_files_trailing_newline_trimmed() {
604        let tmp = tempfile::tempdir().unwrap();
605        let binding = tmp.path().join("my-binding");
606        std::fs::create_dir_all(&binding).unwrap();
607        std::fs::write(binding.join("type"), "agentvisor\n").unwrap();
608        std::fs::write(binding.join("gateway_url"), "  https://gw.example.com  \n").unwrap();
609        std::fs::write(binding.join("audience"), "trimmed\n").unwrap();
610
611        let result = from_binding_root(tmp.path()).unwrap().unwrap();
612        assert_eq!(result.gateway_url, "https://gw.example.com");
613        assert_eq!(result.audience, "trimmed");
614    }
615
616    #[test]
617    fn binding_files_wrong_type_skipped() {
618        let tmp = tempfile::tempdir().unwrap();
619        let binding = tmp.path().join("postgres");
620        std::fs::create_dir_all(&binding).unwrap();
621        std::fs::write(binding.join("type"), "postgres\n").unwrap();
622        std::fs::write(binding.join("host"), "localhost").unwrap();
623
624        assert!(from_binding_root(tmp.path()).unwrap().is_none());
625    }
626
627    #[test]
628    fn binding_files_missing_root_returns_none() {
629        let result = from_binding_root(Path::new("/nonexistent/path/that/does/not/exist"));
630        assert!(result.unwrap().is_none());
631    }
632
633    #[test]
634    fn binding_files_missing_required_field_is_error() {
635        let tmp = tempfile::tempdir().unwrap();
636        let binding = tmp.path().join("agentvisor");
637        std::fs::create_dir_all(&binding).unwrap();
638        std::fs::write(binding.join("type"), "agentvisor").unwrap();
639        std::fs::write(binding.join("gateway_url"), "https://gw.example.com").unwrap();
640        // audience is missing
641
642        let result = from_binding_root(tmp.path());
643        assert!(result.is_err());
644        assert!(matches!(
645            result.unwrap_err(),
646            ServiceBindingError::MissingField { field: "audience" }
647        ));
648    }
649
650    #[test]
651    fn binding_files_hidden_dir_skipped() {
652        let tmp = tempfile::tempdir().unwrap();
653        let hidden = tmp.path().join(".hidden");
654        std::fs::create_dir_all(&hidden).unwrap();
655        std::fs::write(hidden.join("type"), "agentvisor").unwrap();
656        std::fs::write(hidden.join("gateway_url"), "https://gw.example.com").unwrap();
657        std::fs::write(hidden.join("audience"), "hidden").unwrap();
658
659        assert!(from_binding_root(tmp.path()).unwrap().is_none());
660    }
661
662    // ---- try_from_env_with precedence test ----
663
664    #[test]
665    fn vcap_takes_precedence_over_files() {
666        let tmp = tempfile::tempdir().unwrap();
667        let binding = tmp.path().join("agentvisor");
668        std::fs::create_dir_all(&binding).unwrap();
669        std::fs::write(binding.join("type"), "agentvisor").unwrap();
670        std::fs::write(binding.join("gateway_url"), "https://file-gw.example.com").unwrap();
671        std::fs::write(binding.join("audience"), "file-app").unwrap();
672
673        let mut env = HashMap::new();
674        env.insert(
675            "VCAP_SERVICES",
676            r#"{"agentvisor": [{"credentials": {"gateway_url": "https://vcap-gw.example.com", "audience": "vcap-app"}, "name": "test"}]}"#.to_string(),
677        );
678        env.insert("SERVICE_BINDING_ROOT", tmp.path().to_string_lossy().to_string());
679
680        let result = try_from_env_with(lookup_from(env)).unwrap().unwrap();
681        assert_eq!(result.gateway_url, "https://vcap-gw.example.com");
682        assert_eq!(result.audience, "vcap-app");
683    }
684
685    #[test]
686    fn falls_back_to_files_when_vcap_absent() {
687        let tmp = tempfile::tempdir().unwrap();
688        let binding = tmp.path().join("agentvisor");
689        std::fs::create_dir_all(&binding).unwrap();
690        std::fs::write(binding.join("type"), "agentvisor").unwrap();
691        std::fs::write(binding.join("gateway_url"), "https://file-gw.example.com").unwrap();
692        std::fs::write(binding.join("audience"), "file-app").unwrap();
693
694        let mut env = HashMap::new();
695        env.insert("SERVICE_BINDING_ROOT", tmp.path().to_string_lossy().to_string());
696
697        let result = try_from_env_with(lookup_from(env)).unwrap().unwrap();
698        assert_eq!(result.gateway_url, "https://file-gw.example.com");
699    }
700
701    #[test]
702    fn no_binding_anywhere_returns_none() {
703        let tmp = tempfile::tempdir().unwrap();
704        let env = HashMap::from([("SERVICE_BINDING_ROOT", tmp.path().to_string_lossy().to_string())]);
705
706        assert!(try_from_env_with(lookup_from(env)).unwrap().is_none());
707    }
708
709    #[test]
710    fn vcap_no_match_falls_through_to_files() {
711        let tmp = tempfile::tempdir().unwrap();
712        let binding = tmp.path().join("agentvisor");
713        std::fs::create_dir_all(&binding).unwrap();
714        std::fs::write(binding.join("type"), "agentvisor").unwrap();
715        std::fs::write(binding.join("gateway_url"), "https://file-gw.example.com").unwrap();
716        std::fs::write(binding.join("audience"), "file-app").unwrap();
717
718        let env = HashMap::from([
719            (
720                "VCAP_SERVICES",
721                r#"{"postgres": [{"credentials": {"uri": "postgres://..."}, "label": "postgres"}]}"#
722                    .to_string(),
723            ),
724            ("SERVICE_BINDING_ROOT", tmp.path().to_string_lossy().to_string()),
725        ]);
726
727        let result = try_from_env_with(lookup_from(env)).unwrap().unwrap();
728        assert_eq!(result.gateway_url, "https://file-gw.example.com");
729        assert_eq!(result.audience, "file-app");
730    }
731
732    #[test]
733    fn empty_vcap_falls_through_to_files() {
734        let tmp = tempfile::tempdir().unwrap();
735        let binding = tmp.path().join("agentvisor");
736        std::fs::create_dir_all(&binding).unwrap();
737        std::fs::write(binding.join("type"), "agentvisor").unwrap();
738        std::fs::write(binding.join("gateway_url"), "https://file-gw.example.com").unwrap();
739        std::fs::write(binding.join("audience"), "file-app").unwrap();
740
741        let env = HashMap::from([
742            ("VCAP_SERVICES", "{}".to_string()),
743            ("SERVICE_BINDING_ROOT", tmp.path().to_string_lossy().to_string()),
744        ]);
745
746        let result = try_from_env_with(lookup_from(env)).unwrap().unwrap();
747        assert_eq!(result.gateway_url, "https://file-gw.example.com");
748    }
749
750    #[test]
751    fn binding_files_ambiguous_is_error() {
752        let tmp = tempfile::tempdir().unwrap();
753
754        let binding_a = tmp.path().join("agentvisor-a");
755        std::fs::create_dir_all(&binding_a).unwrap();
756        std::fs::write(binding_a.join("type"), "agentvisor").unwrap();
757        std::fs::write(binding_a.join("gateway_url"), "https://a.example.com").unwrap();
758        std::fs::write(binding_a.join("audience"), "app-a").unwrap();
759
760        let binding_b = tmp.path().join("agentvisor-b");
761        std::fs::create_dir_all(&binding_b).unwrap();
762        std::fs::write(binding_b.join("type"), "agentvisor").unwrap();
763        std::fs::write(binding_b.join("gateway_url"), "https://b.example.com").unwrap();
764        std::fs::write(binding_b.join("audience"), "app-b").unwrap();
765
766        let result = from_binding_root(tmp.path());
767        assert!(result.is_err());
768        assert!(matches!(
769            result.unwrap_err(),
770            ServiceBindingError::Ambiguous { count: 2 }
771        ));
772    }
773    #[test]
774    fn vcap_rejects_invalid_structure_and_optional_types() {
775        for invalid in ["null", "[]", r#"{"agentvisor":{}}"#, r#"{"agentvisor":[null]}"#] {
776            assert!(from_vcap_services(invalid).is_err(), "{invalid}");
777        }
778        for optional in ["123", "true", "{}", "[]"] {
779            let invalid = format!(
780                r#"{{"agentvisor":[{{"credentials":{{"gateway_url":"https://gw.example","audience":"app","identity_jwks_url":{optional}}}}}]}}"#
781            );
782            assert!(from_vcap_services(&invalid).is_err(), "{invalid}");
783        }
784    }
785
786    #[test]
787    fn bindings_reject_embedded_control_characters() {
788        let invalid = r#"{"agentvisor":[{"credentials":{"gateway_url":"https://gw.example","audience":"app\u0000injected"}}]}"#;
789        assert!(matches!(
790            from_vcap_services(invalid),
791            Err(ServiceBindingError::InvalidField { field: "audience" })
792        ));
793        let tmp = tempfile::tempdir().unwrap();
794        let binding = tmp.path().join("agentvisor");
795        std::fs::create_dir(&binding).unwrap();
796        std::fs::write(binding.join("type"), "agentvisor").unwrap();
797        std::fs::write(binding.join("gateway_url"), "https://gw.example").unwrap();
798        std::fs::write(binding.join("audience"), "app\ninjected").unwrap();
799        assert!(matches!(
800            from_binding_root(tmp.path()),
801            Err(ServiceBindingError::InvalidField { field: "audience" })
802        ));
803    }
804}