1use serde_json::{Map, Value};
13
14use crate::binding::{BindingEntry, BindingKind, BindingType, EnvBindings, Wanted};
15use crate::registry::{self, InfraForm, SEARCH_PREVIEW_API_VERSION};
16use crate::resources::ResourceKind;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Target {
21 Storage,
22 Identity,
23 ModelHost,
24 AiServices,
25 FunctionApp,
26 Api,
27 KeyVault,
28 SearchService,
29}
30
31impl std::fmt::Display for Target {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 let word = match self {
34 Target::Storage => "storage",
35 Target::Identity => "identity",
36 Target::ModelHost => "model host",
37 Target::AiServices => "ai-services",
38 Target::FunctionApp => "function-app",
39 Target::Api => "api",
40 Target::KeyVault => "key-vault",
41 Target::SearchService => "search",
42 };
43 write!(f, "{word}")
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
50pub struct PhysicalRef {
51 pub target: Target,
52 pub physical: String,
54 pub original: Value,
55 pub kb_name: Option<String>,
57}
58
59#[derive(Debug, Clone)]
61pub struct RenderTarget {
62 pub physical: String,
63 pub arm_id: Option<String>,
64 pub base_url: Option<String>,
65 pub kb_name: Option<String>,
66 pub source_base_url: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq)]
75pub struct FoundRef {
76 pub path: String,
78 pub form: InfraForm,
79 pub physical: PhysicalRef,
80}
81
82#[derive(Debug, Clone, PartialEq)]
84pub enum Class {
85 Bound(String),
88 Shared(String, Vec<String>),
91 Leak { binding: String, envs: Vec<String> },
95 Unbound,
98 External,
101}
102
103#[derive(Debug, Clone, PartialEq)]
105pub struct Classified {
106 pub found: FoundRef,
107 pub class: Class,
108}
109
110const SEARCH_HOST_SUFFIX: &str = ".search.windows.net";
112
113const OPENAI_HOST_SUFFIXES: &[&str] = &[
114 "openai.azure.com",
115 "cognitiveservices.azure.com",
116 "services.ai.azure.com",
117];
118
119pub fn parse(form: InfraForm, value: &Value) -> Option<PhysicalRef> {
123 match form {
124 InfraForm::StorageResourceId => parse_storage(value),
125 InfraForm::UserAssignedIdentity => parse_identity(value),
126 InfraForm::OpenAiEndpoint => parse_host(value, Target::ModelHost),
127 InfraForm::AiServicesSubdomain => parse_host(value, Target::AiServices),
128 InfraForm::ApiUri => parse_api_uri(value),
129 InfraForm::KeyVaultUri => parse_keyvault(value),
130 InfraForm::SearchKbMcpUrl => parse_kb_mcp(value),
131 InfraForm::Endpoint => parse_endpoint(value),
132 }
133}
134
135pub fn render(form: InfraForm, original: &Value, target: &RenderTarget) -> Result<Value, String> {
139 match form {
140 InfraForm::StorageResourceId => render_storage(original, target),
141 InfraForm::UserAssignedIdentity => render_identity(original, target),
142 InfraForm::OpenAiEndpoint => render_host_swap(original, target, OPENAI_HOST_SUFFIXES),
143 InfraForm::AiServicesSubdomain => render_host_swap(original, target, OPENAI_HOST_SUFFIXES),
144 InfraForm::ApiUri => render_api_uri(original, target),
145 InfraForm::KeyVaultUri => render_keyvault(original, target),
146 InfraForm::SearchKbMcpUrl => render_kb_mcp(target),
147 InfraForm::Endpoint => render_endpoint(original, target),
148 }
149}
150
151pub fn extract(kind: ResourceKind, doc: &Value) -> Vec<FoundRef> {
154 let mut out = Vec::new();
155 for ir in registry::infra_refs(kind) {
156 let segments: Vec<&str> = ir.path.split('.').collect();
157 let mut found: Vec<(String, &Value)> = Vec::new();
158 walk_infra(
159 doc,
160 &segments,
161 String::new(),
162 ir.only_odata_type,
163 &mut found,
164 );
165 for (path, value) in found {
166 if let Some(physical) = parse(ir.form, value) {
167 out.push(FoundRef {
168 path,
169 form: ir.form,
170 physical,
171 });
172 }
173 }
174 }
175 out
176}
177
178pub fn classify(
181 this_env: &EnvBindings,
182 other_envs: &[EnvBindings],
183 refs: Vec<FoundRef>,
184) -> Vec<Classified> {
185 refs.into_iter()
186 .map(|found| {
187 let target = found.physical.target;
188 let wanted = wanted_for(target);
189 let ref_url = found.physical.original.as_str().unwrap_or("").to_string();
190 let physical = found.physical.physical.clone();
191
192 let this_hit =
193 find_wanted(this_env, wanted, target, &ref_url, &physical).map(|e| e.name.clone());
194
195 let class = match this_hit {
196 Some(name) => {
197 let shared_envs: Vec<String> = other_envs
198 .iter()
199 .filter(|e| find_wanted(e, wanted, target, &ref_url, &physical).is_some())
200 .map(|e| e.env.clone())
201 .collect();
202 if shared_envs.is_empty() {
203 Class::Bound(name)
204 } else {
205 Class::Shared(name, shared_envs)
206 }
207 }
208 None => {
209 let mut leak_binding: Option<String> = None;
210 let mut envs = Vec::new();
211 for e in other_envs {
212 if let Some(entry) = find_wanted(e, wanted, target, &ref_url, &physical) {
213 if leak_binding.is_none() {
214 leak_binding = Some(entry.name.clone());
215 }
216 envs.push(e.env.clone());
217 }
218 }
219 match leak_binding {
220 Some(binding) => Class::Leak { binding, envs },
221 None if target == Target::Api => Class::External,
222 None => Class::Unbound,
223 }
224 }
225 };
226 Classified { found, class }
227 })
228 .collect()
229}
230
231pub fn binding_type_for(target: Target) -> Option<BindingType> {
234 match target {
235 Target::Storage => Some(BindingType::Storage),
236 Target::Identity => Some(BindingType::Identity),
237 Target::ModelHost | Target::AiServices => Some(BindingType::AiServices),
238 Target::FunctionApp => Some(BindingType::FunctionApp),
239 Target::Api => Some(BindingType::Api),
240 Target::KeyVault => Some(BindingType::KeyVault),
241 Target::SearchService => None,
242 }
243}
244
245pub fn wanted_for(target: Target) -> Wanted {
249 match binding_type_for(target) {
250 Some(kind) => Wanted::for_binding(kind),
251 None => Wanted::SearchService,
252 }
253}
254
255fn find_wanted<'a>(
260 env: &'a EnvBindings,
261 wanted: Wanted,
262 target: Target,
263 ref_url: &str,
264 physical: &str,
265) -> Option<&'a BindingEntry> {
266 if target == Target::Api {
267 find_api_binding(env, ref_url)
268 } else {
269 env.find_physical(wanted, physical)
270 }
271}
272
273pub fn find_api_binding<'a>(env: &'a EnvBindings, ref_url: &str) -> Option<&'a BindingEntry> {
279 let ref_lower = ref_url.to_ascii_lowercase();
280 env.iter().find(|e| {
281 matches!(e.kind, BindingKind::Declared(BindingType::Api))
282 && e.declared.as_ref().is_some_and(|b| {
283 let origin = b.value.trim_end_matches('/').to_ascii_lowercase();
284 if origin.is_empty() || !ref_lower.starts_with(&origin) {
285 return false;
286 }
287 matches!(
291 ref_lower.as_bytes().get(origin.len()),
292 None | Some(b'/') | Some(b'?') | Some(b'#')
293 )
294 })
295 })
296}
297
298fn parse_storage(value: &Value) -> Option<PhysicalRef> {
303 let s = value.as_str()?;
304 let lower = s.to_ascii_lowercase();
308 let start = lower.find("resourceid=")? + "resourceid=".len();
309 let body = &s[start..];
310 let arm_id = body.split(';').next().unwrap_or(body);
311 if arm_id.contains('<') {
312 return None;
313 }
314 let trimmed = arm_id.trim_end_matches('/');
315 let name = crate::binding::arm_resource_name(trimmed)?;
316 Some(PhysicalRef {
317 target: Target::Storage,
318 physical: name.to_ascii_lowercase(),
319 original: value.clone(),
320 kb_name: None,
321 })
322}
323
324fn parse_identity(value: &Value) -> Option<PhysicalRef> {
325 let obj = value.as_object()?;
326 let uid = obj.get("userAssignedIdentity")?.as_str()?;
327 if uid.is_empty() || uid.contains('<') {
328 return None;
329 }
330 let name = crate::binding::arm_resource_name(uid.trim_end_matches('/'))?;
331 Some(PhysicalRef {
332 target: Target::Identity,
333 physical: name.to_ascii_lowercase(),
334 original: value.clone(),
335 kb_name: None,
336 })
337}
338
339fn parse_host(value: &Value, target: Target) -> Option<PhysicalRef> {
340 let url = as_url(value)?;
341 let (host, _tail) = host_and_tail(url);
342 let (name, _suffix) = parse_host_suffix(&host, OPENAI_HOST_SUFFIXES)?;
343 Some(PhysicalRef {
344 target,
345 physical: name,
346 original: value.clone(),
347 kb_name: None,
348 })
349}
350
351fn parse_api_uri(value: &Value) -> Option<PhysicalRef> {
352 let url = as_url(value)?;
353 let (host, _tail) = host_and_tail(url);
354 let host_lower = host.to_ascii_lowercase();
355 if let Some(name) = host_lower.strip_suffix(".azurewebsites.net") {
356 return Some(PhysicalRef {
357 target: Target::FunctionApp,
358 physical: name.to_string(),
359 original: value.clone(),
360 kb_name: None,
361 });
362 }
363 Some(PhysicalRef {
364 target: Target::Api,
365 physical: host_lower,
366 original: value.clone(),
367 kb_name: None,
368 })
369}
370
371fn parse_keyvault(value: &Value) -> Option<PhysicalRef> {
372 let url = as_url(value)?;
373 let (host, _tail) = host_and_tail(url);
374 let name = host
375 .to_ascii_lowercase()
376 .strip_suffix(".vault.azure.net")?
377 .to_string();
378 Some(PhysicalRef {
379 target: Target::KeyVault,
380 physical: name,
381 original: value.clone(),
382 kb_name: None,
383 })
384}
385
386fn parse_kb_mcp(value: &Value) -> Option<PhysicalRef> {
387 let url = as_url(value)?;
388 let (host, tail) = host_and_tail(url);
389 let svc = host
390 .to_ascii_lowercase()
391 .strip_suffix(SEARCH_HOST_SUFFIX)?
392 .to_string();
393 let path_only = tail.split('?').next().unwrap_or(tail);
394 let mut segs = path_only.split('/').filter(|s| !s.is_empty());
395 let a = segs.next()?;
396 let kb = segs.next()?;
397 let c = segs.next()?;
398 if !a.eq_ignore_ascii_case("knowledgebases") || !c.eq_ignore_ascii_case("mcp") {
399 return None;
400 }
401 Some(PhysicalRef {
402 target: Target::SearchService,
403 physical: svc,
404 original: value.clone(),
405 kb_name: Some(kb.to_string()),
406 })
407}
408
409fn parse_endpoint(value: &Value) -> Option<PhysicalRef> {
412 parse_kb_mcp(value)
413 .or_else(|| parse_host(value, Target::ModelHost))
414 .or_else(|| parse_search_endpoint(value))
415 .or_else(|| parse_api_uri(value))
416}
417
418fn parse_search_endpoint(value: &Value) -> Option<PhysicalRef> {
422 let url = as_url(value)?;
423 let (host, _tail) = host_and_tail(url);
424 let svc = host
425 .to_ascii_lowercase()
426 .strip_suffix(SEARCH_HOST_SUFFIX)?
427 .to_string();
428 (!svc.is_empty()).then(|| PhysicalRef {
429 target: Target::SearchService,
430 physical: svc,
431 original: value.clone(),
432 kb_name: None,
433 })
434}
435
436fn render_storage(original: &Value, target: &RenderTarget) -> Result<Value, String> {
441 let arm_id = target
442 .arm_id
443 .as_ref()
444 .ok_or("StorageResourceId render requires arm_id")?;
445 let s = original
446 .as_str()
447 .ok_or("StorageResourceId render requires a string value")?;
448 let lower = s.to_ascii_lowercase();
453 let key_at = lower
454 .find("resourceid=")
455 .ok_or("expected a `ResourceId=` value")?;
456 let value_at = key_at + "resourceid=".len();
457 let head = &s[..value_at];
458 let rest = &s[value_at..];
459 let tail = match rest.find(';') {
460 Some(i) => &rest[i..], None => "",
462 };
463 Ok(Value::String(format!("{head}{arm_id}{tail}")))
464}
465
466fn render_identity(original: &Value, target: &RenderTarget) -> Result<Value, String> {
467 let arm_id = target
468 .arm_id
469 .as_ref()
470 .ok_or("UserAssignedIdentity render requires arm_id")?;
471 let mut obj: Map<String, Value> = original
472 .as_object()
473 .cloned()
474 .ok_or("UserAssignedIdentity render requires an object value")?;
475 obj.insert(
476 "userAssignedIdentity".to_string(),
477 Value::String(arm_id.clone()),
478 );
479 Ok(Value::Object(obj))
480}
481
482fn render_host_swap(
483 original: &Value,
484 target: &RenderTarget,
485 suffixes: &'static [&'static str],
486) -> Result<Value, String> {
487 let url = original
488 .as_str()
489 .ok_or("host-swap render requires a string value")?;
490 let (host, tail) = host_and_tail(url);
491 let (_, suffix) = parse_host_suffix(&host, suffixes)
492 .ok_or_else(|| format!("`{url}` is not a recognized infrastructure host"))?;
493 let scheme = url_scheme(url);
494 Ok(Value::String(format!(
495 "{scheme}://{}.{suffix}{tail}",
496 target.physical
497 )))
498}
499
500fn render_api_uri(original: &Value, target: &RenderTarget) -> Result<Value, String> {
501 let url = original
502 .as_str()
503 .ok_or("ApiUri render requires a string value")?;
504 let (host, tail) = host_and_tail(url);
505 if host.to_ascii_lowercase().ends_with(".azurewebsites.net") {
506 let scheme = url_scheme(url);
507 return Ok(Value::String(format!(
508 "{scheme}://{}.azurewebsites.net{tail}",
509 target.physical
510 )));
511 }
512
513 let base_url = target
514 .base_url
515 .as_ref()
516 .ok_or("Api render requires base_url")?;
517 if let Some(src) = &target.source_base_url {
518 let src_norm = src.trim_end_matches('/').to_ascii_lowercase();
519 let url_lower = url.to_ascii_lowercase();
520 let at_boundary = matches!(
525 url_lower.as_bytes().get(src_norm.len()),
526 None | Some(b'/') | Some(b'?') | Some(b'#')
527 );
528 if url_lower.starts_with(&src_norm) && at_boundary {
529 let remainder = &url[src_norm.len()..];
530 return Ok(Value::String(format!(
531 "{}{remainder}",
532 base_url.trim_end_matches('/')
533 )));
534 }
535 return Err(format!(
536 "source_base_url `{src}` does not prefix reference `{url}`"
537 ));
538 }
539 Ok(Value::String(format!(
540 "{}{tail}",
541 scheme_and_host(base_url)
542 )))
543}
544
545fn render_keyvault(original: &Value, target: &RenderTarget) -> Result<Value, String> {
546 let url = original
547 .as_str()
548 .ok_or("KeyVaultUri render requires a string value")?;
549 let (_, tail) = host_and_tail(url);
550 let scheme = url_scheme(url);
551 Ok(Value::String(format!(
552 "{scheme}://{}.vault.azure.net{tail}",
553 target.physical
554 )))
555}
556
557fn render_kb_mcp(target: &RenderTarget) -> Result<Value, String> {
558 let kb = target
559 .kb_name
560 .as_deref()
561 .ok_or("SearchKbMcpUrl render requires kb_name")?;
562 Ok(Value::String(format!(
563 "https://{}.search.windows.net/knowledgebases/{kb}/mcp?api-version={SEARCH_PREVIEW_API_VERSION}",
564 target.physical
565 )))
566}
567
568fn render_endpoint(original: &Value, target: &RenderTarget) -> Result<Value, String> {
571 let parsed = parse_endpoint(original)
572 .ok_or_else(|| format!("`{original}` is not a recognized endpoint"))?;
573 match (parsed.target, parsed.kb_name.is_some()) {
574 (Target::SearchService, true) => render_kb_mcp(target),
575 (Target::SearchService, false) => {
576 let url = original
577 .as_str()
578 .ok_or("Endpoint render requires a string value")?;
579 let (_, tail) = host_and_tail(url);
580 let scheme = url_scheme(url);
581 Ok(Value::String(format!(
582 "{scheme}://{}{SEARCH_HOST_SUFFIX}{tail}",
583 target.physical
584 )))
585 }
586 (Target::ModelHost, _) => render_host_swap(original, target, OPENAI_HOST_SUFFIXES),
587 _ => render_api_uri(original, target),
588 }
589}
590
591fn as_url(value: &Value) -> Option<&str> {
596 let s = value.as_str()?;
597 let looks_like_url = s.starts_with("http://") || s.starts_with("https://");
598 if !looks_like_url {
599 return None;
600 }
601 let (host, _tail) = host_and_tail(s);
606 (!host.contains('<')).then_some(s)
607}
608
609fn host_and_tail(url: &str) -> (String, &str) {
612 let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
613 match after_scheme.find('/') {
614 Some(i) => (after_scheme[..i].to_string(), &after_scheme[i..]),
615 None => (after_scheme.to_string(), ""),
616 }
617}
618
619fn scheme_and_host(url: &str) -> String {
621 match url.find("://") {
622 Some(i) => {
623 let after = &url[i + 3..];
624 match after.find('/') {
625 Some(j) => url[..i + 3 + j].to_string(),
626 None => url.to_string(),
627 }
628 }
629 None => url.to_string(),
630 }
631}
632
633fn url_scheme(url: &str) -> &'static str {
634 if url.starts_with("https://") {
635 "https"
636 } else {
637 "http"
638 }
639}
640
641fn parse_host_suffix(
644 host: &str,
645 suffixes: &'static [&'static str],
646) -> Option<(String, &'static str)> {
647 let lower = host.to_ascii_lowercase();
648 for &suffix in suffixes {
649 if let Some(name) = lower.strip_suffix(&format!(".{suffix}"))
650 && !name.is_empty()
651 {
652 return Some((name.to_string(), suffix));
653 }
654 }
655 None
656}
657
658fn odata_type_matches(constraint: &str, actual: &str) -> bool {
664 actual.rsplit('.').next() == constraint.rsplit('.').next()
665}
666
667fn walk_infra<'a>(
673 v: &'a Value,
674 segments: &[&str],
675 prefix: String,
676 only_odata_type: Option<&str>,
677 out: &mut Vec<(String, &'a Value)>,
678) {
679 let Some((head, rest)) = segments.split_first() else {
680 out.push((prefix, v));
681 return;
682 };
683 if let Some(key) = head.strip_suffix("[]") {
684 let target = if key.is_empty() { Some(v) } else { v.get(key) };
685 if let Some(Value::Array(arr)) = target {
686 for (i, item) in arr.iter().enumerate() {
687 if let Some(constraint) = only_odata_type {
688 let actual = item
689 .get("@odata.type")
690 .and_then(Value::as_str)
691 .unwrap_or("");
692 if !odata_type_matches(constraint, actual) {
693 continue;
694 }
695 }
696 let new_prefix = if prefix.is_empty() {
697 format!("{key}[{i}]")
698 } else {
699 format!("{prefix}.{key}[{i}]")
700 };
701 walk_infra(item, rest, new_prefix, only_odata_type, out);
702 }
703 }
704 } else if let Some(next) = v.get(*head) {
705 let new_prefix = if prefix.is_empty() {
706 (*head).to_string()
707 } else {
708 format!("{prefix}.{head}")
709 };
710 walk_infra(next, rest, new_prefix, only_odata_type, out);
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use crate::binding::Binding;
718 use crate::workspace::{Environment, FoundryConnection, SearchConnection};
719 use serde_json::json;
720
721 #[test]
725 fn binding_type_labels_match_binding_type_for() {
726 let single = [
727 (InfraForm::StorageResourceId, Target::Storage),
728 (InfraForm::UserAssignedIdentity, Target::Identity),
729 (InfraForm::OpenAiEndpoint, Target::ModelHost),
730 (InfraForm::AiServicesSubdomain, Target::AiServices),
731 (InfraForm::KeyVaultUri, Target::KeyVault),
732 ];
733 for (form, target) in single {
734 let expected = binding_type_for(target)
735 .expect("a declared binding type")
736 .to_string();
737 assert_eq!(form.binding_type_label(), expected, "{form:?}");
738 }
739 assert!(binding_type_for(Target::SearchService).is_none());
742 assert_eq!(InfraForm::SearchKbMcpUrl.binding_type_label(), "search");
743 for form in [InfraForm::ApiUri, InfraForm::Endpoint] {
745 let label = form.binding_type_label();
746 for target in [Target::FunctionApp, Target::Api] {
747 let name = binding_type_for(target).expect("declared").to_string();
748 assert!(label.contains(&name), "{form:?} label omits {name}");
749 }
750 }
751 }
752
753 fn env_with(
754 deps: &[(&str, BindingType, &str)],
755 search_svc: &str,
756 foundry_acct: &str,
757 ) -> Environment {
758 Environment {
759 search: Some(SearchConnection {
760 service: search_svc.to_string(),
761 ..Default::default()
762 }),
763 foundry: Some(FoundryConnection {
764 account: foundry_acct.to_string(),
765 project: "p".to_string(),
766 ..Default::default()
767 }),
768 dependencies: deps
769 .iter()
770 .map(|(name, kind, value)| {
771 (
772 name.to_string(),
773 Binding {
774 kind: *kind,
775 value: value.to_string(),
776 },
777 )
778 })
779 .collect(),
780 ..Default::default()
781 }
782 }
783
784 fn found(form: InfraForm, path: &str, target: Target, physical: &str) -> FoundRef {
785 FoundRef {
786 path: path.to_string(),
787 form,
788 physical: PhysicalRef {
789 target,
790 physical: physical.to_string(),
791 original: json!(format!("https://{physical}/probe")),
792 kb_name: None,
793 },
794 }
795 }
796
797 #[test]
798 fn parse_and_render_storage_resource_id() {
799 let v = json!(
800 "ResourceId=/subscriptions/S/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/MKLabAcct/;Database=x"
801 );
802 let p = parse(InfraForm::StorageResourceId, &v).unwrap();
803 assert_eq!(
804 (p.target, p.physical.as_str()),
805 (Target::Storage, "mklabacct")
806 );
807 let out = render(
808 InfraForm::StorageResourceId,
809 &v,
810 &RenderTarget {
811 physical: "prodacct".into(),
812 arm_id: Some(
813 "/subscriptions/P/resourceGroups/PRG/providers/Microsoft.Storage/storageAccounts/prodacct"
814 .into(),
815 ),
816 base_url: None,
817 kb_name: None,
818 source_base_url: None,
819 },
820 )
821 .unwrap();
822 assert_eq!(
823 out,
824 json!(
825 "ResourceId=/subscriptions/P/resourceGroups/PRG/providers/Microsoft.Storage/storageAccounts/prodacct;Database=x"
826 )
827 );
828 assert!(
829 parse(
830 InfraForm::StorageResourceId,
831 &json!("ResourceId=/subscriptions/<sub>/…")
832 )
833 .is_none()
834 );
835 }
836
837 #[test]
838 fn parse_user_assigned_identity() {
839 let id = json!({
840 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
841 "userAssignedIdentity": "/subscriptions/S/resourcegroups/RG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Rigg-Dev"
842 });
843 assert_eq!(
844 parse(InfraForm::UserAssignedIdentity, &id)
845 .unwrap()
846 .physical,
847 "rigg-dev"
848 );
849 assert!(parse(InfraForm::UserAssignedIdentity, &json!(null)).is_none());
850 }
851
852 #[test]
853 fn parse_and_render_openai_endpoint_keeps_path() {
854 let e = json!("https://MKLabAIFNDR.openai.azure.com/");
855 let p = parse(InfraForm::OpenAiEndpoint, &e).unwrap();
856 assert_eq!(
857 (p.target, p.physical.as_str()),
858 (Target::ModelHost, "mklabaifndr")
859 );
860 assert_eq!(
861 render(
862 InfraForm::OpenAiEndpoint,
863 &e,
864 &RenderTarget {
865 physical: "prodaifndr".into(),
866 arm_id: None,
867 base_url: None,
868 kb_name: None,
869 source_base_url: None,
870 },
871 )
872 .unwrap(),
873 json!("https://prodaifndr.openai.azure.com/")
874 );
875 }
876
877 #[test]
878 fn parse_and_render_function_app_vs_external_api() {
879 let f = json!("https://mklab.azurewebsites.net/api/enrich?code=<redacted>");
880 let p = parse(InfraForm::ApiUri, &f).unwrap();
881 assert_eq!(
882 (p.target, p.physical.as_str()),
883 (Target::FunctionApp, "mklab")
884 );
885 assert_eq!(
886 render(
887 InfraForm::ApiUri,
888 &f,
889 &RenderTarget {
890 physical: "mklab-prod".into(),
891 arm_id: None,
892 base_url: None,
893 kb_name: None,
894 source_base_url: None,
895 },
896 )
897 .unwrap(),
898 json!("https://mklab-prod.azurewebsites.net/api/enrich?code=<redacted>")
899 );
900
901 let x = json!("https://api.partner.example/v1/enrich");
902 assert_eq!(parse(InfraForm::ApiUri, &x).unwrap().target, Target::Api);
903 assert_eq!(
904 render(
905 InfraForm::ApiUri,
906 &x,
907 &RenderTarget {
908 physical: "api.partner-prod.example".into(),
909 arm_id: None,
910 base_url: Some("https://api.partner-prod.example/v2".into()),
911 kb_name: None,
912 source_base_url: Some("https://api.partner.example/v1".into()),
913 },
914 )
915 .unwrap_or(json!(null)),
916 json!("https://api.partner-prod.example/v2/enrich")
917 );
918 }
919
920 #[test]
921 fn parse_key_vault_uri() {
922 assert_eq!(
923 parse(
924 InfraForm::KeyVaultUri,
925 &json!("https://mklabkv.vault.azure.net/keys/k/1")
926 )
927 .unwrap()
928 .physical,
929 "mklabkv"
930 );
931 }
932
933 #[test]
934 fn parse_and_render_search_kb_mcp_url() {
935 let m = json!(
936 "https://mklabsrch.search.windows.net/knowledgeBases/regulatory-kb/mcp?api-version=old"
937 );
938 let p = parse(InfraForm::SearchKbMcpUrl, &m).unwrap();
939 assert_eq!(
940 (p.target, p.physical.as_str(), p.kb_name.as_deref()),
941 (Target::SearchService, "mklabsrch", Some("regulatory-kb"))
942 );
943 let r = render(
944 InfraForm::SearchKbMcpUrl,
945 &m,
946 &RenderTarget {
947 physical: "mklabsrch-prod".into(),
948 arm_id: None,
949 base_url: None,
950 kb_name: Some("regulatory-kb".into()),
951 source_base_url: None,
952 },
953 )
954 .unwrap();
955 assert_eq!(
956 r,
957 json!(format!(
958 "https://mklabsrch-prod.search.windows.net/knowledgebases/regulatory-kb/mcp?api-version={}",
959 crate::registry::SEARCH_PREVIEW_API_VERSION
960 ))
961 );
962 }
963
964 #[test]
965 fn extract_walks_arrays_with_concrete_paths() {
966 let skillset = json!({"name": "ss", "skills": [
967 {"@odata.type": "#Microsoft.Skills.Text.SplitSkill"},
968 {"@odata.type": "#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill", "resourceUri": "https://mklabaifndr.openai.azure.com"},
969 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill", "uri": "https://mklab.azurewebsites.net/api/x"}
970 ], "cognitiveServices": {"@odata.type": "#Microsoft.Azure.Search.AIServicesByIdentity", "subdomainUrl": "https://mklabaisrvc.cognitiveservices.azure.com/"}});
971 let refs = extract(ResourceKind::Skillset, &skillset);
972 let paths: Vec<&str> = refs.iter().map(|r| r.path.as_str()).collect();
973 assert!(paths.contains(&"skills[1].resourceUri"), "{paths:?}");
974 assert!(paths.contains(&"skills[2].uri"), "{paths:?}");
975 assert!(
976 paths.contains(&"cognitiveServices.subdomainUrl"),
977 "{paths:?}"
978 );
979 assert_eq!(refs.len(), 3);
980 }
981
982 #[test]
983 fn classify_bound_shared_leak_unbound_external() {
984 let dev = EnvBindings::of_env(
985 "dev",
986 &env_with(
987 &[
988 ("docs", BindingType::Storage, "acct"),
989 ("fn", BindingType::FunctionApp, "mklab"),
990 ],
991 "mklabsrch",
992 "mklabaifndr",
993 ),
994 None,
995 );
996 let prod = EnvBindings::of_env(
997 "prod",
998 &env_with(
999 &[
1000 ("docs", BindingType::Storage, "acct"),
1001 ("fn", BindingType::FunctionApp, "mklab-prod"),
1002 ],
1003 "mklabsrch-prod",
1004 "mklabaifndr-prod",
1005 ),
1006 None,
1007 );
1008 let refs = vec![
1009 found(
1010 InfraForm::StorageResourceId,
1011 "credentials.connectionString",
1012 Target::Storage,
1013 "acct",
1014 ),
1015 found(
1016 InfraForm::ApiUri,
1017 "skills[0].uri",
1018 Target::FunctionApp,
1019 "mklab-prod",
1020 ),
1021 found(
1022 InfraForm::OpenAiEndpoint,
1023 "skills[1].resourceUri",
1024 Target::ModelHost,
1025 "mklabaifndr",
1026 ),
1027 found(
1028 InfraForm::KeyVaultUri,
1029 "encryptionKey.keyVaultUri",
1030 Target::KeyVault,
1031 "kv",
1032 ),
1033 found(
1034 InfraForm::ApiUri,
1035 "skills[2].uri",
1036 Target::Api,
1037 "api.partner.example",
1038 ),
1039 ];
1040 let out = classify(&dev, &[prod], refs);
1041 assert!(
1042 matches!(&out[0].class, Class::Shared(b, envs) if b == "docs" && envs == &vec!["prod".to_string()])
1043 );
1044 assert!(
1045 matches!(&out[1].class, Class::Leak { binding, envs } if binding == "fn" && envs == &vec!["prod".to_string()])
1046 );
1047 assert!(matches!(&out[2].class, Class::Bound(b) if b == "foundry"));
1048 assert!(matches!(out[3].class, Class::Unbound));
1049 assert!(matches!(out[4].class, Class::External));
1050 }
1051
1052 #[test]
1053 fn extract_builds_dotted_concrete_path_when_array_segment_is_not_first() {
1054 let index = json!({
1058 "name": "idx",
1059 "vectorSearch": {
1060 "vectorizers": [
1061 {
1062 "kind": "azureOpenAI",
1063 "azureOpenAIParameters": {
1064 "resourceUri": "https://mklabaifndr.openai.azure.com"
1065 }
1066 }
1067 ]
1068 }
1069 });
1070 let refs = extract(ResourceKind::Index, &index);
1071 let paths: Vec<&str> = refs.iter().map(|r| r.path.as_str()).collect();
1072 assert_eq!(
1073 paths,
1074 vec!["vectorSearch.vectorizers[0].azureOpenAIParameters.resourceUri"],
1075 "{paths:?}"
1076 );
1077 }
1078
1079 #[test]
1080 fn extract_finds_web_api_skill_auth_identity() {
1081 let skillset = json!({"name": "ss", "skills": [
1086 {
1087 "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
1088 "uri": "https://mklab.azurewebsites.net/api/x",
1089 "authIdentity": {
1090 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1091 "userAssignedIdentity": "/subscriptions/S/resourcegroups/RG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Rigg-Dev"
1092 }
1093 }
1094 ]});
1095 let refs = extract(ResourceKind::Skillset, &skillset);
1096 let paths: Vec<&str> = refs.iter().map(|r| r.path.as_str()).collect();
1097 assert!(paths.contains(&"skills[0].authIdentity"), "{paths:?}");
1098 }
1099
1100 #[test]
1101 fn odata_type_matches_requires_exact_last_segment_not_suffix() {
1102 assert!(odata_type_matches(
1103 "#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill",
1104 "#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill"
1105 ));
1106 assert!(!odata_type_matches(
1109 "#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill",
1110 "#Contoso.Skills.MyAzureOpenAIEmbeddingSkill"
1111 ));
1112 }
1113
1114 #[test]
1115 fn parse_storage_locates_resourceid_case_insensitively_anywhere() {
1116 let v = json!(
1117 "AccountName=x;ResourceId=/subscriptions/S/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/MKLabAcct"
1118 );
1119 assert_eq!(
1120 parse(InfraForm::StorageResourceId, &v).unwrap().physical,
1121 "mklabacct"
1122 );
1123 let lower = json!(
1124 "resourceid=/subscriptions/S/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/MKLabAcct"
1125 );
1126 assert_eq!(
1127 parse(InfraForm::StorageResourceId, &lower)
1128 .unwrap()
1129 .physical,
1130 "mklabacct"
1131 );
1132 }
1133
1134 #[test]
1135 fn url_forms_reject_placeholder_hosts() {
1136 assert!(
1137 parse(
1138 InfraForm::OpenAiEndpoint,
1139 &json!("https://<account>.openai.azure.com")
1140 )
1141 .is_none()
1142 );
1143 }
1144
1145 fn plain_target(physical: &str) -> RenderTarget {
1146 RenderTarget {
1147 physical: physical.to_string(),
1148 arm_id: None,
1149 base_url: None,
1150 kb_name: None,
1151 source_base_url: None,
1152 }
1153 }
1154
1155 #[test]
1156 fn render_storage_locates_resourceid_anywhere_like_parse() {
1157 let v = json!(
1160 "AccountName=x;resourceid=/subscriptions/S/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/devacct/;Database=d"
1161 );
1162 let out = render(
1163 InfraForm::StorageResourceId,
1164 &v,
1165 &RenderTarget {
1166 arm_id: Some(
1167 "/subscriptions/P/resourceGroups/PRG/providers/Microsoft.Storage/storageAccounts/prodacct".into(),
1168 ),
1169 ..plain_target("prodacct")
1170 },
1171 )
1172 .unwrap();
1173 assert_eq!(
1174 out,
1175 json!(
1176 "AccountName=x;resourceid=/subscriptions/P/resourceGroups/PRG/providers/Microsoft.Storage/storageAccounts/prodacct;Database=d"
1177 )
1178 );
1179 assert_eq!(
1181 parse(InfraForm::StorageResourceId, &out).unwrap().physical,
1182 "prodacct"
1183 );
1184 assert!(
1185 render(
1186 InfraForm::StorageResourceId,
1187 &json!("AccountName=x"),
1188 &RenderTarget {
1189 arm_id: Some("/subscriptions/P/x".into()),
1190 ..plain_target("p")
1191 },
1192 )
1193 .is_err()
1194 );
1195 }
1196
1197 #[test]
1198 fn render_api_uri_enforces_the_same_url_boundary_as_matching() {
1199 let evil = json!("https://api.partner.example.evil.test/x");
1203 assert!(
1204 render(
1205 InfraForm::ApiUri,
1206 &evil,
1207 &RenderTarget {
1208 base_url: Some("https://api.partner-prod.example/v2".into()),
1209 source_base_url: Some("https://api.partner.example".into()),
1210 ..plain_target("api.partner-prod.example")
1211 },
1212 )
1213 .is_err()
1214 );
1215 let ok = json!("https://api.partner.example/v1/enrich");
1216 assert_eq!(
1217 render(
1218 InfraForm::ApiUri,
1219 &ok,
1220 &RenderTarget {
1221 base_url: Some("https://api.partner-prod.example/v2".into()),
1222 source_base_url: Some("https://api.partner.example/v1".into()),
1223 ..plain_target("api.partner-prod.example")
1224 },
1225 )
1226 .unwrap(),
1227 json!("https://api.partner-prod.example/v2/enrich")
1228 );
1229 }
1230
1231 #[test]
1232 fn render_round_trips_identity_ai_services_and_key_vault() {
1233 let id = json!({
1234 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1235 "userAssignedIdentity": "/subscriptions/S/resourcegroups/RG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/rigg-dev"
1236 });
1237 let rendered = render(
1238 InfraForm::UserAssignedIdentity,
1239 &id,
1240 &RenderTarget {
1241 arm_id: Some(
1242 "/subscriptions/P/resourcegroups/PRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/rigg-prod".into(),
1243 ),
1244 ..plain_target("rigg-prod")
1245 },
1246 )
1247 .unwrap();
1248 assert_eq!(
1249 parse(InfraForm::UserAssignedIdentity, &rendered)
1250 .unwrap()
1251 .physical,
1252 "rigg-prod"
1253 );
1254 assert_eq!(
1255 rendered["@odata.type"],
1256 json!("#Microsoft.Azure.Search.DataUserAssignedIdentity"),
1257 "everything that isn't the infrastructure part is kept"
1258 );
1259
1260 let sub = json!("https://mklabaisrvc.cognitiveservices.azure.com/vision");
1261 let rendered = render(
1262 InfraForm::AiServicesSubdomain,
1263 &sub,
1264 &plain_target("prodaisrvc"),
1265 )
1266 .unwrap();
1267 assert_eq!(
1268 rendered,
1269 json!("https://prodaisrvc.cognitiveservices.azure.com/vision")
1270 );
1271 let back = parse(InfraForm::AiServicesSubdomain, &rendered).unwrap();
1272 assert_eq!(
1273 (back.target, back.physical.as_str()),
1274 (Target::AiServices, "prodaisrvc")
1275 );
1276
1277 let kv = json!("https://mklabkv.vault.azure.net/keys/k/1");
1278 let rendered = render(InfraForm::KeyVaultUri, &kv, &plain_target("prodkv")).unwrap();
1279 assert_eq!(rendered, json!("https://prodkv.vault.azure.net/keys/k/1"));
1280 assert_eq!(
1281 parse(InfraForm::KeyVaultUri, &rendered).unwrap().physical,
1282 "prodkv"
1283 );
1284 }
1285
1286 #[test]
1287 fn find_api_binding_respects_url_boundary() {
1288 let dev = EnvBindings::of_env(
1289 "dev",
1290 &env_with(
1291 &[("api", BindingType::Api, "https://api.partner.example")],
1292 "mklabsrch",
1293 "mklabaifndr",
1294 ),
1295 None,
1296 );
1297 let refs = vec![found(
1298 InfraForm::ApiUri,
1299 "skills[0].uri",
1300 Target::Api,
1301 "api.partner.example.evil.test",
1302 )];
1303 let out = classify(&dev, &[], refs);
1304 assert!(matches!(out[0].class, Class::External));
1305 }
1306
1307 fn to(physical: &str) -> RenderTarget {
1308 RenderTarget {
1309 physical: physical.to_string(),
1310 arm_id: None,
1311 base_url: None,
1312 kb_name: None,
1313 source_base_url: None,
1314 }
1315 }
1316
1317 #[test]
1318 fn endpoint_form_parses_and_renders_a_kb_mcp_url() {
1319 let url = json!(format!(
1320 "https://s-dev.search.windows.net/knowledgebases/kb-dev/mcp?api-version={SEARCH_PREVIEW_API_VERSION}"
1321 ));
1322 let p = parse(InfraForm::Endpoint, &url).unwrap();
1323 assert_eq!(
1324 (p.target, p.physical.as_str(), p.kb_name.as_deref()),
1325 (Target::SearchService, "s-dev", Some("kb-dev"))
1326 );
1327 assert_eq!(
1328 render(
1329 InfraForm::Endpoint,
1330 &url,
1331 &RenderTarget {
1332 kb_name: Some("kb".into()),
1333 ..to("s-prod")
1334 },
1335 )
1336 .unwrap(),
1337 json!(format!(
1338 "https://s-prod.search.windows.net/knowledgebases/kb/mcp?api-version={SEARCH_PREVIEW_API_VERSION}"
1339 ))
1340 );
1341 }
1342
1343 #[test]
1344 fn endpoint_form_parses_and_renders_a_model_host() {
1345 for host in [
1346 "https://acct-dev.openai.azure.com",
1347 "https://acct-dev.cognitiveservices.azure.com/",
1348 "https://acct-dev.services.ai.azure.com/api/projects/p",
1349 ] {
1350 let url = json!(host);
1351 let p = parse(InfraForm::Endpoint, &url).unwrap();
1352 assert_eq!(
1353 (p.target, p.physical.as_str(), p.kb_name.as_deref()),
1354 (Target::ModelHost, "acct-dev", None),
1355 "{host}"
1356 );
1357 let rendered = render(InfraForm::Endpoint, &url, &to("acct-prod")).unwrap();
1358 assert_eq!(
1359 rendered,
1360 json!(host.replace("acct-dev", "acct-prod")),
1361 "{host}"
1362 );
1363 }
1364 }
1365
1366 #[test]
1367 fn endpoint_form_parses_and_renders_a_bare_search_endpoint() {
1368 let url = json!("https://s-dev.search.windows.net");
1369 let p = parse(InfraForm::Endpoint, &url).unwrap();
1370 assert_eq!(
1371 (p.target, p.physical.as_str(), p.kb_name.as_deref()),
1372 (Target::SearchService, "s-dev", None)
1373 );
1374 assert_eq!(
1375 render(InfraForm::Endpoint, &url, &to("s-prod")).unwrap(),
1376 json!("https://s-prod.search.windows.net")
1377 );
1378 let indexes = json!("https://s-dev.search.windows.net/indexes/docs");
1380 assert_eq!(
1381 parse(InfraForm::Endpoint, &indexes).unwrap().target,
1382 Target::SearchService
1383 );
1384 assert_eq!(
1385 render(InfraForm::Endpoint, &indexes, &to("s-prod")).unwrap(),
1386 json!("https://s-prod.search.windows.net/indexes/docs")
1387 );
1388 }
1389
1390 #[test]
1391 fn endpoint_form_falls_back_to_the_api_uri_rules() {
1392 let f = json!("https://fn-dev.azurewebsites.net/api/enrich");
1393 let p = parse(InfraForm::Endpoint, &f).unwrap();
1394 assert_eq!(
1395 (p.target, p.physical.as_str()),
1396 (Target::FunctionApp, "fn-dev")
1397 );
1398 assert_eq!(
1399 render(InfraForm::Endpoint, &f, &to("fn-prod")).unwrap(),
1400 json!("https://fn-prod.azurewebsites.net/api/enrich")
1401 );
1402
1403 let x = json!("https://api.partner.example/v1/enrich");
1404 assert_eq!(parse(InfraForm::Endpoint, &x).unwrap().target, Target::Api);
1405 assert_eq!(
1406 render(
1407 InfraForm::Endpoint,
1408 &x,
1409 &RenderTarget {
1410 base_url: Some("https://api.partner-prod.example/v2".into()),
1411 source_base_url: Some("https://api.partner.example/v1".into()),
1412 ..to("api.partner-prod.example")
1413 },
1414 )
1415 .unwrap(),
1416 json!("https://api.partner-prod.example/v2/enrich")
1417 );
1418 }
1419
1420 #[test]
1421 fn a_connection_target_naming_another_environments_search_service_is_a_leak() {
1422 let dev = EnvBindings::of_env("dev", &env_with(&[], "s-dev", "f-dev"), None);
1423 let prod = EnvBindings::of_env("prod", &env_with(&[], "s-prod", "f-prod"), None);
1424 let connection = json!({
1425 "name": "kb-conn",
1426 "properties": {"category": "CognitiveSearch", "target": "https://s-dev.search.windows.net"}
1427 });
1428 let refs = extract(ResourceKind::Connection, &connection);
1429 assert_eq!(refs.len(), 1, "{refs:?}");
1430 assert_eq!(refs[0].path, "properties.target");
1431 let out = classify(&prod, &[dev], refs);
1432 assert!(
1433 matches!(&out[0].class, Class::Leak { binding, envs } if binding == "search" && envs == &vec!["dev".to_string()]),
1434 "{:?}",
1435 out[0].class
1436 );
1437 }
1438}