1use std::path::Path;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ServiceBinding {
17 pub gateway_url: String,
20 pub audience: String,
22 pub identity_jwks_url: Option<String>,
24}
25
26#[derive(Debug, thiserror::Error)]
28pub enum ServiceBindingError {
29 #[error("VCAP_SERVICES is not valid JSON: {0}")]
31 InvalidJson(#[from] serde_json::Error),
32
33 #[error("invalid VCAP_SERVICES structure: {0}")]
35 InvalidStructure(&'static str),
36
37 #[error("invalid binding field: {field}")]
39 InvalidField {
40 field: &'static str,
42 },
43
44 #[error("ambiguous binding: found {count} matching agentvisor entries")]
47 Ambiguous {
48 count: usize,
50 },
51
52 #[error("binding is missing required field: {field}")]
54 MissingField {
55 field: &'static str,
57 },
58
59 #[error("binding field is empty: {field}")]
61 EmptyField {
62 field: &'static str,
64 },
65
66 #[error("failed to read binding file: {0}")]
68 Io(#[from] std::io::Error),
69}
70
71const DEFAULT_BINDING_ROOT: &str = "/platform/bindings";
74
75pub fn try_from_env() -> Result<Option<ServiceBinding>, ServiceBindingError> {
86 try_from_env_with(|key| std::env::var(key))
87}
88
89fn try_from_env_with<F>(lookup: F) -> Result<Option<ServiceBinding>, ServiceBindingError>
93where
94 F: Fn(&str) -> Result<String, std::env::VarError>,
95{
96 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 }
108 }
109
110 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
119pub 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 return Err(ServiceBindingError::MissingField { field: "credentials" });
174 }
175 };
176 binding_from_creds(creds)
177 }
178 n => Err(ServiceBindingError::Ambiguous { count: n }),
179 }
180}
181
182fn 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
207fn 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
222fn 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
241fn 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
265pub 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 if name_str.starts_with('.') {
286 continue;
287 }
288
289 let binding_dir = entry.path();
290
291 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 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
326fn 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
340fn 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
353fn 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 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 #[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 #[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 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 #[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}