Skip to main content

rigg_core/
infra.rs

1//! Infrastructure references: recognizing, rewriting, and classifying the
2//! values named by the registry's [`crate::registry::InfraRef`] table.
3//!
4//! [`parse`] turns a raw JSON value into a [`PhysicalRef`] (the physical
5//! Azure resource it names); [`render`] does the inverse — rewrite a value
6//! for a different physical resource, keeping everything about the original
7//! that isn't the infrastructure part. [`extract`] walks a whole document
8//! per its kind's `infra_refs` table and returns every reference found, with
9//! concrete (indexed) paths. [`classify`] compares each found reference
10//! against an environment's declared bindings.
11
12use 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/// The kind of physical Azure resource an infrastructure reference names.
19#[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/// A recognized infrastructure reference: what it points at, and enough of
48/// the original value to rewrite it later.
49#[derive(Debug, Clone, PartialEq)]
50pub struct PhysicalRef {
51    pub target: Target,
52    /// Lowercase resource name (or host, for URL-shaped forms).
53    pub physical: String,
54    pub original: Value,
55    /// The knowledge-base name, for [`InfraForm::SearchKbMcpUrl`] only.
56    pub kb_name: Option<String>,
57}
58
59/// The physical resource `render` should rewrite a value to point at.
60#[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    /// The *source* binding's base URL, for [`InfraForm::ApiUri`]'s `Api`
67    /// case — the prefix `render` should replace with `base_url`. When
68    /// absent, only the scheme and host are swapped.
69    pub source_base_url: Option<String>,
70}
71
72/// One infrastructure reference found in a document, at a concrete
73/// (indexed) path.
74#[derive(Debug, Clone, PartialEq)]
75pub struct FoundRef {
76    /// Concrete path, e.g. `skills[2].uri`.
77    pub path: String,
78    pub form: InfraForm,
79    pub physical: PhysicalRef,
80}
81
82/// How a [`FoundRef`] relates to `this_env`'s declared bindings.
83#[derive(Debug, Clone, PartialEq)]
84pub enum Class {
85    /// Bound to a declared (or implicit) binding in this environment, named
86    /// `String`, and not bound anywhere else.
87    Bound(String),
88    /// Bound in this environment (named `String`), and *also* bound to the
89    /// same physical resource in the listed other environments.
90    Shared(String, Vec<String>),
91    /// Not bound in this environment, but bound (to `binding`) in the listed
92    /// other environments — the reference "leaked" a value that belongs to
93    /// another environment.
94    Leak { binding: String, envs: Vec<String> },
95    /// Not bound anywhere, and not an `Api` reference (so not presumed
96    /// external either).
97    Unbound,
98    /// An `Api` reference bound nowhere — presumed to be a genuinely
99    /// external, environment-independent endpoint.
100    External,
101}
102
103/// One [`FoundRef`] together with its [`Class`].
104#[derive(Debug, Clone, PartialEq)]
105pub struct Classified {
106    pub found: FoundRef,
107    pub class: Class,
108}
109
110/// The host suffix of every Azure AI Search data-plane endpoint.
111const 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
119/// Parse a raw value per `form` into the [`PhysicalRef`] it names. `None`
120/// when the value is absent, null, a placeholder, or not recognized as this
121/// form's shape.
122pub 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
135/// Rewrite `original` per `form`, pointing at `target` instead — keeping
136/// everything about `original` that isn't the infrastructure part (e.g. a
137/// connection string's `Database=` tail, a URI's path and query).
138pub 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
151/// Every infrastructure reference in `doc`, per `kind`'s `infra_refs` table,
152/// at concrete (indexed) paths.
153pub 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
178/// Classify every ref in `refs` against `this_env`'s bindings, checking
179/// `other_envs` for sharing/leaking.
180pub 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
231/// The [`BindingType`] a reference to `target` would bind to, or `None` for
232/// the search service (which is an environment target, never a dependency).
233pub 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
245/// What a reference to `target` competes for when looking a binding up in an
246/// environment: the declared type it would bind to, or the implicit `search`
247/// target.
248pub 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
255/// Look up a binding matching `wanted` in `env`. `Api` references are
256/// matched by URL prefix against declared `api` bindings rather than by
257/// exact physical-name equality (an `api` binding's value may be a base URL
258/// with a path, e.g. `https://api.partner.example/v1`).
259fn 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
273/// The declared `api` binding whose base URL prefixes `ref_url`, matched at a
274/// URL boundary (`/`, `?`, `#`, or the end of the URL). The rule
275/// [`classify`] applies to [`Target::Api`] references — `rigg promote` uses
276/// it too, so "which binding does this URL belong to" answers the same
277/// everywhere.
278pub 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                // The match must end at a URL boundary — `origin` prefixing
288                // `ref_lower` isn't enough, or `https://api.partner.example`
289                // would match `https://api.partner.example.evil.test/x`.
290                matches!(
291                    ref_lower.as_bytes().get(origin.len()),
292                    None | Some(b'/') | Some(b'?') | Some(b'#')
293                )
294            })
295    })
296}
297
298// ---------------------------------------------------------------------
299// parse
300// ---------------------------------------------------------------------
301
302fn parse_storage(value: &Value) -> Option<PhysicalRef> {
303    let s = value.as_str()?;
304    // Locate `ResourceId=` case-insensitively, anywhere in the connection
305    // string (aligned with `identity::parse_resource_id`) — it need not be
306    // the first key (e.g. `AccountName=x;ResourceId=...`).
307    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
409/// The composite [`InfraForm::Endpoint`]: try each endpoint shape in turn,
410/// most specific first, and fall back to the `ApiUri` rules.
411fn 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
418/// A bare `https://X.search.windows.net[/…]` endpoint — the implicit
419/// `search` target with no knowledge base. (The KB-MCP shape is recognized
420/// first, by [`parse_kb_mcp`].)
421fn 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
436// ---------------------------------------------------------------------
437// render
438// ---------------------------------------------------------------------
439
440fn 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    // Symmetric with `parse_storage`: locate `ResourceId=` case-insensitively
449    // anywhere in the connection string and splice the new id in around it,
450    // so both the head (e.g. `AccountName=x;`) and the tail (e.g.
451    // `;Database=x`) survive untouched.
452    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..], // keeps the `;`
461        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        // Same URL boundary rule as `find_api_binding`: a prefix match must
521        // end at `/`, `?`, `#` or the end of the URL, so the binding for
522        // `https://api.partner.example` never claims
523        // `https://api.partner.example.evil.test/x`.
524        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
568/// The composite [`InfraForm::Endpoint`]: rewrite `original` per the shape
569/// [`parse_endpoint`] recognizes it as.
570fn 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
591// ---------------------------------------------------------------------
592// shared helpers
593// ---------------------------------------------------------------------
594
595fn 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    // Reject a placeholder host (e.g. `https://<account>.openai.azure.com`),
602    // same as the storage/identity forms — but only in the host, not the
603    // path/query, where a literal `<...>` can legitimately appear (e.g. a
604    // redacted secret: `?code=<redacted>`).
605    let (host, _tail) = host_and_tail(s);
606    (!host.contains('<')).then_some(s)
607}
608
609/// Split a URL into its host and the tail (path + query, including the
610/// leading `/`, or empty when there is none).
611fn 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
619/// The scheme + host of `url` (drops any path/query).
620fn 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
641/// Strip one of `suffixes` (each matched as `.{suffix}`, case-insensitively)
642/// from `host`, returning the remaining name and the matched suffix.
643fn 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
658/// `constraint`'s last dot-segment (e.g. `"AzureOpenAIEmbeddingSkill"` from
659/// `"#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill"`) must equal
660/// `actual`'s last dot-segment — so any namespace ending in that exact skill
661/// type name matches, but a same-suffixed-but-different type (e.g.
662/// `MyAzureOpenAIEmbeddingSkill`) does not.
663fn odata_type_matches(constraint: &str, actual: &str) -> bool {
664    actual.rsplit('.').next() == constraint.rsplit('.').next()
665}
666
667/// Walk `segments` (registry path syntax) from `v`, appending matched
668/// terminal `(concrete_path, &Value)` pairs to `out`. `[]` segments expand
669/// to concrete indices (`skills[2]`); when `only_odata_type` is set, an
670/// array element is only descended into when its `@odata.type` matches (see
671/// [`odata_type_matches`]).
672fn 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    /// `InfraForm::binding_type_label` documents the same mapping this
722    /// module classifies by, so the generated reference table cannot drift
723    /// away from `binding_type_for`.
724    #[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        // The search service is the implicit environment target, not a
740        // declared dependency: `binding_type_for` has nothing to return.
741        assert!(binding_type_for(Target::SearchService).is_none());
742        assert_eq!(InfraForm::SearchKbMcpUrl.binding_type_label(), "search");
743        // Composite forms name every type they can yield.
744        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        // Regression: the `[]` segment in `vectorSearch.vectorizers[]...` is
1055        // not the first path segment, so the concrete path must keep the `.`
1056        // separator between `vectorSearch` and `vectorizers[0]`.
1057        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        // `skills[].authIdentity` is not gated on any particular skill type
1082        // (the spec table has no skill-type annotation for that row), so a
1083        // WebApiSkill's authIdentity must be found too, not just an
1084        // AzureOpenAIEmbeddingSkill's.
1085        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        // `MyAzureOpenAIEmbeddingSkill` ends with the constraint's tail but
1107        // is not the same skill type — must not match.
1108        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        // `ResourceId=` is neither first nor lowercase-canonical: the head
1158        // before it and the tail after the value must both survive.
1159        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        // and the rendered value parses back to the new account
1180        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        // `https://api.partner.example` must not be spliced out of
1200        // `https://api.partner.example.evil.test/x` — the same boundary rule
1201        // `find_api_binding` applies when deciding the binding matches.
1202        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        // A path that is not the KB-MCP shape is kept as it is.
1379        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}