Skip to main content

discovery/
standards.rs

1use crate::classifier::{Classification, ClassifierRegistry, Validity};
2use crate::probe::{Probe, ProbeRegistry};
3use serde_json::Value;
4use url::Url;
5
6pub const HANDOFF_REL: &str = "https://www.sliccy.ai/rel/handoff";
7pub const UPSKILL_REL: &str = "https://www.sliccy.ai/rel/upskill";
8pub const LLMS_TXT_REL: &str = "https://llmstxt.org/rel/llms-txt";
9
10const TITLE_CAP: usize = 200;
11const HEADINGS_CAP: usize = 50;
12const HEADING_LEN_CAP: usize = 200;
13const CHALLENGE_CAP: usize = 500;
14const AUTH_MD_METHODS_CAP: usize = 20;
15const AUTH_MD_METHOD_LEN_CAP: usize = 200;
16
17const ENTRY_TYPE_CATALOG: &str = "application/ai-catalog+json";
18const ENTRY_TYPE_REGISTRY: &str = "application/ai-registry+json";
19
20const ENTRIES_CAP: usize = 200;
21const DISPLAY_NAME_CAP: usize = 200;
22const IDENTIFIER_CAP: usize = 500;
23const QUERIES_CAP: usize = 10;
24const QUERY_LEN_CAP: usize = 200;
25const CAPABILITIES_CAP: usize = 50;
26const CAPABILITY_LEN_CAP: usize = 100;
27
28pub fn known_link_rels() -> &'static [&'static str] {
29    &[
30        "ard",
31        "ai-catalog",
32        "llms-txt",
33        "api-catalog",
34        "ucp",
35        "describedby",
36        "service-desc",
37        "service-meta",
38        "status",
39        "service-doc",
40        LLMS_TXT_REL,
41        HANDOFF_REL,
42        UPSKILL_REL,
43        "auth-md",
44        "oauth-authorization-server",
45        "oauth-protected-resource",
46    ]
47}
48
49pub fn default_probes() -> ProbeRegistry {
50    let mut registry = ProbeRegistry::new();
51    registry.register(Probe {
52        standard: "ard".into(),
53        path: "/.well-known/ard.json".into(),
54        accept: "application/json".into(),
55        content_type_prefix: "application/json".into(),
56    });
57    registry.register(Probe {
58        standard: "ai-catalog".into(),
59        path: "/.well-known/ai-catalog.json".into(),
60        accept: "application/json".into(),
61        content_type_prefix: "application/json".into(),
62    });
63    registry.register(Probe {
64        standard: "api-catalog".into(),
65        path: "/.well-known/api-catalog".into(),
66        accept: "application/linkset+json, application/json;q=0.9".into(),
67        content_type_prefix: "application/".into(),
68    });
69    registry.register(Probe {
70        standard: "llms-txt".into(),
71        path: "/llms.txt".into(),
72        accept: "text/plain, text/markdown".into(),
73        content_type_prefix: "text/".into(),
74    });
75    registry.register(Probe {
76        standard: "poppy".into(),
77        path: "/.well-known/poppy.json".into(),
78        accept: "application/json".into(),
79        content_type_prefix: "application/json".into(),
80    });
81    registry.register(Probe {
82        standard: "ucp".into(),
83        path: "/.well-known/ucp".into(),
84        accept: "application/json".into(),
85        content_type_prefix: "application/json".into(),
86    });
87    registry.register(Probe {
88        standard: "auth-md".into(),
89        path: "/auth.md".into(),
90        accept: "text/markdown, text/plain".into(),
91        content_type_prefix: "text/".into(),
92    });
93    registry
94}
95
96pub fn default_classifiers() -> ClassifierRegistry {
97    let mut registry = ClassifierRegistry::new();
98    registry.register("ard", classify_ard);
99    registry.register("ai-catalog", classify_ai_catalog);
100    registry.register("api-catalog", classify_api_catalog);
101    registry.register("llms-txt", classify_llms_txt);
102    registry.register("poppy", classify_poppy);
103    registry.register("auth-md", classify_auth_md);
104    registry.register("ucp", crate::ucp::classify_ucp);
105    registry
106}
107
108pub fn default_llmstxtignore_entries() -> &'static [&'static str] {
109    &["github.com", "app.slack.com"]
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ResponseSignal {
114    pub standard: String,
115    pub summary: Value,
116}
117
118pub fn classify_response(status: u16, headers: &[(String, String)], response_url: Option<&str>) -> Vec<ResponseSignal> {
119    let mut signals = Vec::new();
120    if status == 401 {
121        let schemes = parse_www_authenticate_headers(headers);
122        let auth_links = extract_auth_links(headers, response_url);
123        let mut summary = serde_json::Map::new();
124        summary.insert("status".into(), Value::Number(401.into()));
125        if !schemes.is_empty() {
126            summary.insert("schemes".into(), Value::Array(schemes));
127        }
128        if !auth_links.is_empty() {
129            summary.insert("links".into(), Value::Array(auth_links));
130        }
131        signals.push(ResponseSignal {
132            standard: "auth-md".into(),
133            summary: Value::Object(summary),
134        });
135    }
136    signals
137}
138
139
140fn parse_www_authenticate_headers(headers: &[(String, String)]) -> Vec<Value> {
141    let mut schemes = Vec::new();
142    for (name, value) in headers {
143        if !name.eq_ignore_ascii_case("www-authenticate") {
144            continue;
145        }
146        let capped = truncate(value, CHALLENGE_CAP);
147        for challenge in split_challenges(&capped) {
148            if let Some(parsed) = parse_single_challenge(&challenge) {
149                schemes.push(parsed);
150            }
151        }
152    }
153    schemes
154}
155
156pub(crate) fn split_challenges(raw: &str) -> Vec<String> {
157    let mut challenges = Vec::new();
158    let mut current = String::new();
159    let mut in_quotes = false;
160    let mut chars = raw.chars().peekable();
161    while let Some(ch) = chars.next() {
162        match ch {
163            '"' => {
164                in_quotes = !in_quotes;
165                current.push(ch);
166            }
167            '\\' if in_quotes => {
168                current.push(ch);
169                if let Some(next) = chars.next() {
170                    current.push(next);
171                }
172            }
173            ',' if !in_quotes => {
174                let trimmed = current.trim().to_string();
175                if !trimmed.is_empty() {
176                    let has_scheme = trimmed.split_ascii_whitespace().next()
177                        .map_or(false, |w| w.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
178                    let looks_like_param = trimmed.contains('=') && !trimmed.contains(' ');
179                    if has_scheme && !looks_like_param {
180                        challenges.push(trimmed);
181                        current = String::new();
182                    } else if let Some(last) = challenges.last_mut() {
183                        last.push(',');
184                        last.push_str(&trimmed);
185                        current = String::new();
186                    } else {
187                        challenges.push(trimmed);
188                        current = String::new();
189                    }
190                } else {
191                    current = String::new();
192                }
193            }
194            _ => current.push(ch),
195        }
196    }
197    let trimmed = current.trim().to_string();
198    if !trimmed.is_empty() {
199        let looks_like_param = trimmed.contains('=') && !trimmed.contains(' ');
200        if looks_like_param {
201            if let Some(last) = challenges.last_mut() {
202                last.push(',');
203                last.push_str(&trimmed);
204            } else {
205                challenges.push(trimmed);
206            }
207        } else {
208            challenges.push(trimmed);
209        }
210    }
211    challenges
212}
213
214fn parse_single_challenge(challenge: &str) -> Option<Value> {
215    let trimmed = challenge.trim();
216    if trimmed.is_empty() {
217        return None;
218    }
219    let space = trimmed.find(|c: char| c.is_ascii_whitespace());
220    let scheme = match space {
221        Some(pos) => &trimmed[..pos],
222        None => trimmed,
223    };
224    if !scheme.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
225        return None;
226    }
227    let mut map = serde_json::Map::new();
228    map.insert("scheme".into(), Value::String(scheme.to_string()));
229    if let Some(pos) = space {
230        let params_str = &trimmed[pos..].trim();
231        let params = parse_challenge_params(params_str);
232        if let Some(realm) = params.get("realm") {
233            map.insert("realm".into(), Value::String(truncate(realm, TITLE_CAP)));
234        }
235        for key in &["resource_metadata", "as_uri"] {
236            if let Some(url_val) = params.get(*key) {
237                let safe = strip_userinfo_val(url_val);
238                map.insert((*key).into(), Value::String(safe));
239            }
240        }
241    }
242    Some(Value::Object(map))
243}
244
245pub(crate) fn parse_challenge_params(raw: &str) -> std::collections::BTreeMap<String, String> {
246    let mut params = std::collections::BTreeMap::new();
247    let mut remaining = raw.trim();
248    while !remaining.is_empty() {
249        remaining = remaining.trim_start_matches([' ', ',']);
250        if remaining.is_empty() {
251            break;
252        }
253        let eq_pos = match remaining.find('=') {
254            Some(p) => p,
255            None => break,
256        };
257        let key = remaining[..eq_pos].trim().to_ascii_lowercase();
258        remaining = &remaining[eq_pos + 1..];
259        let value = if remaining.starts_with('"') {
260            let (val, rest) = extract_quoted(remaining);
261            remaining = rest;
262            val
263        } else {
264            let end = remaining.find([',', ' ']).unwrap_or(remaining.len());
265            let val = remaining[..end].to_string();
266            remaining = &remaining[end..];
267            val
268        };
269        if !params.contains_key(&key) {
270            params.insert(key, value);
271        }
272    }
273    params
274}
275
276fn extract_quoted(input: &str) -> (String, &str) {
277    let mut chars = input[1..].chars();
278    let mut value = String::new();
279    let mut consumed = 1;
280    loop {
281        match chars.next() {
282            None => break,
283            Some('"') => {
284                consumed += 1;
285                break;
286            }
287            Some('\\') => {
288                consumed += 1;
289                if let Some(c) = chars.next() {
290                    value.push(c);
291                    consumed += c.len_utf8();
292                }
293            }
294            Some(c) => {
295                value.push(c);
296                consumed += c.len_utf8();
297            }
298        }
299    }
300    (value, &input[consumed..])
301}
302
303fn extract_auth_links(headers: &[(String, String)], response_url: Option<&str>) -> Vec<Value> {
304    let mut links = Vec::new();
305    let auth_rels = [
306        "auth-md",
307        "oauth-authorization-server",
308        "oauth-protected-resource",
309    ];
310    let fallback_base = Url::parse("http://placeholder.invalid/").unwrap();
311    let base = response_url
312        .and_then(|u| Url::parse(u).ok())
313        .unwrap_or(fallback_base);
314    for (name, value) in headers {
315        if !name.eq_ignore_ascii_case("link") {
316            continue;
317        }
318        let parsed = crate::link::parse_link_header(value, &base);
319        for link in parsed {
320            if auth_rels.contains(&link.rel.as_str()) {
321                let safe_href = strip_userinfo_val(&link.href);
322                let mut entry = serde_json::Map::new();
323                entry.insert("rel".into(), Value::String(link.rel.clone()));
324                entry.insert("href".into(), Value::String(safe_href));
325                links.push(Value::Object(entry));
326            }
327        }
328    }
329    links
330}
331
332fn strip_userinfo_val(url_str: &str) -> String {
333    match Url::parse(url_str) {
334        Ok(mut parsed) => {
335            if parsed.username().is_empty() && parsed.password().is_none() {
336                return url_str.to_string();
337            }
338            let _ = parsed.set_username("");
339            let _ = parsed.set_password(None);
340            parsed.to_string()
341        }
342        Err(_) => url_str.to_string(),
343    }
344}
345
346fn classify_auth_md(body: &str) -> Classification {
347    if body.trim_start().starts_with('<') {
348        return Classification {
349            standard: "auth-md".into(),
350            status: Validity::Invalid,
351            summary: Value::Null,
352            reason: Some("looks like HTML".into()),
353        };
354    }
355    let summary = parse_auth_md_summary(body);
356    Classification {
357        standard: "auth-md".into(),
358        status: Validity::Valid,
359        summary,
360        reason: None,
361    }
362}
363
364pub fn parse_auth_md_summary(body: &str) -> Value {
365    let mut title: Option<String> = None;
366    let mut methods: Vec<String> = Vec::new();
367    let mut oauth_metadata: Vec<String> = Vec::new();
368    let mut claim_window: Option<String> = None;
369
370    for line in body.lines() {
371        let trimmed = line.trim();
372
373        if title.is_none() {
374            if let Some(rest) = trimmed.strip_prefix("# ") {
375                let t = rest.trim();
376                if !t.is_empty() {
377                    title = Some(truncate(t, TITLE_CAP));
378                }
379                continue;
380            }
381        }
382
383        let lower = trimmed.to_ascii_lowercase();
384        if (lower.starts_with("- ") || lower.starts_with("* ")) && methods.len() < AUTH_MD_METHODS_CAP {
385            let item = trimmed[2..].trim();
386            let item_lower = item.to_ascii_lowercase();
387            if item_lower.contains("oauth") || item_lower.contains("openid")
388                || item_lower.contains("api key") || item_lower.contains("api_key")
389                || item_lower.contains("apikey") || item_lower.contains("bearer")
390                || item_lower.contains("signup") || item_lower.contains("sign up")
391                || item_lower.contains("sign-up") || item_lower.contains("register")
392                || item_lower.contains("email") || item_lower.contains("anonymous")
393                || item_lower.contains("vouched") || item_lower.contains("token")
394            {
395                methods.push(truncate(item, AUTH_MD_METHOD_LEN_CAP));
396            }
397        }
398
399        if lower.contains("claim_window") || lower.contains("claim window") || lower.contains("claimwindow") {
400            if claim_window.is_none() {
401                claim_window = Some(truncate(trimmed, TITLE_CAP));
402            }
403        }
404
405        if let Some(url_start) = find_url_in_line(trimmed) {
406            let url_lower = url_start.to_ascii_lowercase();
407            if url_lower.contains("oauth") || url_lower.contains("openid")
408                || url_lower.contains(".well-known/oauth-authorization-server")
409                || url_lower.contains(".well-known/openid-configuration")
410            {
411                let safe = strip_userinfo_val(url_start);
412                if !oauth_metadata.contains(&safe) {
413                    oauth_metadata.push(safe);
414                }
415            }
416        }
417    }
418
419    let mut map = serde_json::Map::new();
420    if let Some(t) = title {
421        map.insert("title".into(), Value::String(t));
422    }
423    if !methods.is_empty() {
424        map.insert(
425            "methods".into(),
426            Value::Array(methods.into_iter().map(Value::String).collect()),
427        );
428    }
429    if !oauth_metadata.is_empty() {
430        map.insert(
431            "oauth_metadata".into(),
432            Value::Array(oauth_metadata.into_iter().map(Value::String).collect()),
433        );
434    }
435    if let Some(cw) = claim_window {
436        map.insert("claim_window".into(), Value::String(cw));
437    }
438    Value::Object(map)
439}
440
441fn find_url_in_line(line: &str) -> Option<&str> {
442    for prefix in ["https://", "http://"] {
443        if let Some(start) = line.find(prefix) {
444            let rest = &line[start..];
445            let end = rest.find(|c: char| c.is_ascii_whitespace() || c == ')' || c == '>' || c == '"' || c == '\'')
446                .unwrap_or(rest.len());
447            return Some(&rest[..end]);
448        }
449    }
450    None
451}
452
453fn classify_ard(body: &str) -> Classification {
454    classify_ard_with_origin(body, None)
455}
456
457fn classify_ard_with_origin(body: &str, origin: Option<&str>) -> Classification {
458    let origin_domain = origin.and_then(|o| {
459        Url::parse(o).ok().and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
460    });
461    match serde_json::from_str::<Value>(body) {
462        Ok(value) => classify_ard_value(&value, origin_domain.as_deref()),
463        Err(e) => Classification {
464            standard: "ard".into(),
465            status: Validity::Invalid,
466            summary: Value::Null,
467            reason: Some(e.to_string()),
468        },
469    }
470}
471
472pub fn classify_ard_body(body: &str, origin: Option<&str>) -> Classification {
473    classify_ard_with_origin(body, origin)
474}
475
476fn classify_ai_catalog(body: &str) -> Classification {
477    classify_ai_catalog_with_origin(body, None)
478}
479
480fn classify_ai_catalog_with_origin(body: &str, origin: Option<&str>) -> Classification {
481    let origin_domain = origin.and_then(|o| {
482        Url::parse(o).ok().and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
483    });
484    match serde_json::from_str::<Value>(body) {
485        Ok(value) => {
486            let mut c = classify_ard_value(&value, origin_domain.as_deref());
487            c.standard = "ai-catalog".into();
488            c
489        }
490        Err(e) => Classification {
491            standard: "ai-catalog".into(),
492            status: Validity::Invalid,
493            summary: Value::Null,
494            reason: Some(e.to_string()),
495        },
496    }
497}
498
499pub fn classify_ai_catalog_body(body: &str, origin: Option<&str>) -> Classification {
500    classify_ai_catalog_with_origin(body, origin)
501}
502
503fn classify_ard_value(value: &Value, origin_domain: Option<&str>) -> Classification {
504    let name = value
505        .get("name")
506        .and_then(|v| v.as_str())
507        .unwrap_or("")
508        .to_string();
509    let name = truncate(&name, TITLE_CAP);
510
511    let entries = match value.get("entries").and_then(|v| v.as_array()) {
512        Some(arr) => arr,
513        None => {
514            return Classification {
515                standard: "ard".into(),
516                status: Validity::Valid,
517                summary: serde_json::json!({ "name": name, "entries": [], "warnings": ["no entries array"] }),
518                reason: None,
519            };
520        }
521    };
522
523    let mut valid_entries: Vec<Value> = Vec::new();
524    let mut warnings: Vec<String> = Vec::new();
525    let mut type_groups: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
526    let mut catalogs: Vec<Value> = Vec::new();
527
528    for (idx, entry) in entries.iter().take(ENTRIES_CAP).enumerate() {
529        let identifier = entry.get("identifier").and_then(|v| v.as_str()).unwrap_or("");
530        let display_name = entry.get("displayName").and_then(|v| v.as_str()).unwrap_or("");
531        let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
532        let has_url = entry.get("url").and_then(|v| v.as_str()).is_some();
533        let has_data = entry.get("data").is_some();
534
535        if identifier.is_empty() || display_name.is_empty() || entry_type.is_empty() {
536            warnings.push(format!("entry {idx}: missing required field (identifier, displayName, or type)"));
537            continue;
538        }
539        if !has_url && !has_data {
540            warnings.push(format!("entry {idx}: missing url and data"));
541            continue;
542        }
543        if has_url && has_data {
544            warnings.push(format!("entry {idx}: has both url and data"));
545            continue;
546        }
547
548        let urn_check = check_urn_publisher(identifier, origin_domain);
549        let trust_check = check_trust_identity(entry, origin_domain);
550
551        if let Some(ref warn) = urn_check {
552            warnings.push(format!("entry {idx}: {warn}"));
553            continue;
554        }
555        if let Some(ref warn) = trust_check {
556            warnings.push(format!("entry {idx}: {warn}"));
557        }
558
559        *type_groups.entry(entry_type.to_string()).or_insert(0) += 1;
560
561        if entry_type == ENTRY_TYPE_CATALOG || entry_type == ENTRY_TYPE_REGISTRY {
562            let mut cat = serde_json::Map::new();
563            cat.insert("identifier".into(), Value::String(truncate(identifier, IDENTIFIER_CAP)));
564            cat.insert("displayName".into(), Value::String(truncate(display_name, DISPLAY_NAME_CAP)));
565            cat.insert("type".into(), Value::String(entry_type.to_string()));
566            if let Some(url) = entry.get("url").and_then(|v| v.as_str()) {
567                cat.insert("url".into(), Value::String(url.to_string()));
568            }
569            catalogs.push(Value::Object(cat));
570        }
571
572        let mut summary_entry = serde_json::Map::new();
573        summary_entry.insert("identifier".into(), Value::String(truncate(identifier, IDENTIFIER_CAP)));
574        summary_entry.insert("displayName".into(), Value::String(truncate(display_name, DISPLAY_NAME_CAP)));
575        summary_entry.insert("type".into(), Value::String(entry_type.to_string()));
576        if let Some(url) = entry.get("url").and_then(|v| v.as_str()) {
577            summary_entry.insert("url".into(), Value::String(url.to_string()));
578        }
579        if let Some(caps) = entry.get("capabilities").and_then(|v| v.as_array()) {
580            let capped: Vec<Value> = caps.iter()
581                .take(CAPABILITIES_CAP)
582                .filter_map(|c| c.as_str().map(|s| Value::String(truncate(s, CAPABILITY_LEN_CAP))))
583                .collect();
584            if !capped.is_empty() {
585                summary_entry.insert("capabilities".into(), Value::Array(capped));
586            }
587        }
588        if let Some(queries) = entry.get("representativeQueries").and_then(|v| v.as_array()) {
589            let capped: Vec<Value> = queries.iter()
590                .take(QUERIES_CAP)
591                .filter_map(|q| q.as_str().map(|s| Value::String(truncate(s, QUERY_LEN_CAP))))
592                .collect();
593            if !capped.is_empty() {
594                summary_entry.insert("representativeQueries".into(), Value::Array(capped));
595            }
596        }
597        valid_entries.push(Value::Object(summary_entry));
598    }
599
600    let mut summary = serde_json::Map::new();
601    summary.insert("name".into(), Value::String(name));
602    summary.insert("entries".into(), Value::Array(valid_entries));
603    if !type_groups.is_empty() {
604        let groups: serde_json::Map<String, Value> = type_groups
605            .into_iter()
606            .map(|(k, v)| (k, Value::Number(serde_json::Number::from(v))))
607            .collect();
608        summary.insert("types".into(), Value::Object(groups));
609    }
610    if !catalogs.is_empty() {
611        summary.insert("catalogs".into(), Value::Array(catalogs));
612    }
613    if !warnings.is_empty() {
614        summary.insert("warnings".into(), Value::Array(
615            warnings.into_iter().map(Value::String).collect()
616        ));
617    }
618
619    Classification {
620        standard: "ard".into(),
621        status: Validity::Valid,
622        summary: Value::Object(summary),
623        reason: None,
624    }
625}
626
627pub fn registrable_domain(host: &str) -> Option<String> {
628    let host = host.strip_suffix('.').unwrap_or(host);
629    let bytes = host.as_bytes();
630    let suffix = psl::suffix(bytes)?;
631    if suffix.is_known() {
632        let domain = psl::domain(bytes)?;
633        Some(std::str::from_utf8(domain.as_bytes()).ok()?.to_ascii_lowercase())
634    } else {
635        None
636    }
637}
638
639fn check_urn_publisher(identifier: &str, origin_domain: Option<&str>) -> Option<String> {
640    let origin_domain = origin_domain?;
641    let prefix = "urn:air:";
642    if !identifier.starts_with(prefix) {
643        return None;
644    }
645    let rest = &identifier[prefix.len()..];
646    let publisher = rest.split(':').next().unwrap_or("");
647    if publisher.is_empty() {
648        return Some("URN has empty publisher".into());
649    }
650    let publisher_lower = publisher.to_ascii_lowercase();
651    let publisher_reg = registrable_domain(&publisher_lower);
652    let origin_reg = registrable_domain(origin_domain);
653    match (publisher_reg, origin_reg) {
654        (Some(p), Some(o)) if p == o => None,
655        (Some(p), Some(o)) => Some(format!(
656            "URN publisher {publisher} (registrable: {p}) does not match origin (registrable: {o})"
657        )),
658        _ => {
659            if publisher_lower == origin_domain.to_ascii_lowercase() {
660                None
661            } else {
662                Some(format!("URN publisher {publisher} does not match origin {origin_domain}"))
663            }
664        }
665    }
666}
667
668fn check_trust_identity(entry: &Value, origin_domain: Option<&str>) -> Option<String> {
669    let origin_domain = origin_domain?;
670    let identity = entry
671        .get("trustManifest")
672        .and_then(|tm| tm.get("identity"))
673        .and_then(|id| id.as_str())?;
674    let identity_lower = identity.to_ascii_lowercase();
675    let identity_reg = registrable_domain(&identity_lower);
676    let origin_reg = registrable_domain(origin_domain);
677    match (identity_reg, origin_reg) {
678        (Some(i), Some(o)) if i == o => None,
679        (Some(i), Some(o)) => Some(format!(
680            "trustManifest.identity {identity} (registrable: {i}) does not match origin (registrable: {o})"
681        )),
682        _ => {
683            if identity_lower == origin_domain.to_ascii_lowercase() {
684                None
685            } else {
686                Some(format!("trustManifest.identity {identity} does not match origin {origin_domain}"))
687            }
688        }
689    }
690}
691
692pub fn parse_robots_txt_agentmap(body: &str) -> Vec<String> {
693    let mut urls = Vec::new();
694    for line in body.lines() {
695        let trimmed = line.trim();
696        if let Some(rest) = trimmed.strip_prefix("Agentmap:") {
697            let url = rest.trim();
698            if !url.is_empty() {
699                urls.push(url.to_string());
700            }
701        } else if let Some(rest) = trimmed.strip_prefix("agentmap:") {
702            let url = rest.trim();
703            if !url.is_empty() {
704                urls.push(url.to_string());
705            }
706        } else {
707            let lower = trimmed.to_ascii_lowercase();
708            if let Some(rest_lower) = lower.strip_prefix("agentmap:") {
709                let colon_pos = trimmed.find(':').unwrap();
710                let url = trimmed[colon_pos + 1..].trim();
711                let _ = rest_lower;
712                if !url.is_empty() {
713                    urls.push(url.to_string());
714                }
715            }
716        }
717    }
718    urls
719}
720
721fn classify_api_catalog(body: &str) -> Classification {
722    match serde_json::from_str::<Value>(body) {
723        Ok(value) => {
724            let has_linkset = value.get("linkset").is_some();
725            Classification {
726                standard: "api-catalog".into(),
727                status: Validity::Valid,
728                summary: if has_linkset {
729                    serde_json::json!({ "format": "linkset" })
730                } else {
731                    serde_json::json!({})
732                },
733                reason: None,
734            }
735        }
736        Err(e) => Classification {
737            standard: "api-catalog".into(),
738            status: Validity::Invalid,
739            summary: Value::Null,
740            reason: Some(e.to_string()),
741        },
742    }
743}
744
745fn classify_llms_txt(body: &str) -> Classification {
746    let summary = parse_llms_txt_summary(body);
747    Classification {
748        standard: "llms-txt".into(),
749        status: Validity::Valid,
750        summary,
751        reason: None,
752    }
753}
754
755pub fn parse_llms_txt_summary(body: &str) -> Value {
756    let mut title: Option<String> = None;
757    let mut blockquote: Option<String> = None;
758    let mut headings: Vec<String> = Vec::new();
759    let mut in_blockquote = false;
760    let mut bq_lines: Vec<String> = Vec::new();
761
762    for line in body.lines() {
763        let trimmed = line.trim();
764
765        if title.is_none() {
766            if let Some(rest) = trimmed.strip_prefix("# ") {
767                let t = rest.trim();
768                if !t.is_empty() {
769                    title = Some(truncate(t, TITLE_CAP));
770                }
771                continue;
772            }
773        }
774
775        if trimmed.starts_with("> ") || trimmed == ">" {
776            in_blockquote = true;
777            let content = trimmed.strip_prefix("> ").unwrap_or("").trim();
778            bq_lines.push(content.to_string());
779            continue;
780        }
781
782        if in_blockquote {
783            in_blockquote = false;
784            if blockquote.is_none() {
785                let joined = bq_lines.join(" ").trim().to_string();
786                if !joined.is_empty() {
787                    blockquote = Some(truncate(&joined, TITLE_CAP));
788                }
789            }
790            bq_lines.clear();
791        }
792
793        if trimmed.starts_with("## ") && headings.len() < HEADINGS_CAP {
794            let heading = trimmed.strip_prefix("## ").unwrap_or("").trim();
795            if !heading.is_empty() {
796                headings.push(truncate(heading, HEADING_LEN_CAP));
797            }
798        }
799    }
800
801    if in_blockquote && blockquote.is_none() {
802        let joined = bq_lines.join(" ").trim().to_string();
803        if !joined.is_empty() {
804            blockquote = Some(truncate(&joined, TITLE_CAP));
805        }
806    }
807
808    let mut map = serde_json::Map::new();
809    if let Some(t) = title {
810        map.insert("title".into(), Value::String(t));
811    }
812    if let Some(bq) = blockquote {
813        map.insert("summary".into(), Value::String(bq));
814    }
815    if !headings.is_empty() {
816        map.insert(
817            "headings".into(),
818            Value::Array(headings.into_iter().map(Value::String).collect()),
819        );
820    }
821    Value::Object(map)
822}
823
824fn classify_poppy(body: &str) -> Classification {
825    classify_poppy_with_origin(body, None)
826}
827
828fn classify_poppy_with_origin(body: &str, origin: Option<&str>) -> Classification {
829    let origin_domain = origin.and_then(|o| {
830        Url::parse(o).ok().and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
831    });
832    match serde_json::from_str::<Value>(body) {
833        Ok(value) => classify_poppy_value(&value, origin_domain.as_deref()),
834        Err(e) => Classification {
835            standard: "poppy".into(),
836            status: Validity::Invalid,
837            summary: Value::Null,
838            reason: Some(e.to_string()),
839        },
840    }
841}
842
843pub fn classify_poppy_body(body: &str, origin: Option<&str>) -> Classification {
844    classify_poppy_with_origin(body, origin)
845}
846
847fn strip_www(host: &str) -> &str {
848    host.strip_prefix("www.").unwrap_or(host)
849}
850
851fn classify_poppy_value(value: &Value, origin_domain: Option<&str>) -> Classification {
852    let protocol_version = value.get("protocol_version").and_then(|v| v.as_str()).unwrap_or("");
853    if protocol_version.is_empty() {
854        return Classification {
855            standard: "poppy".into(),
856            status: Validity::Invalid,
857            summary: Value::Null,
858            reason: Some("missing protocol_version".into()),
859        };
860    }
861    let parts: Vec<&str> = protocol_version.split('.').collect();
862    if parts.len() != 2
863        || parts[0].parse::<u32>().is_err()
864        || parts[1].parse::<u32>().is_err()
865    {
866        return Classification {
867            standard: "poppy".into(),
868            status: Validity::Invalid,
869            summary: Value::Null,
870            reason: Some(format!("invalid protocol_version: {protocol_version}")),
871        };
872    }
873
874    let org = value.get("organization");
875    let org_domain = org
876        .and_then(|o| o.get("domain"))
877        .and_then(|d| d.as_str())
878        .unwrap_or("");
879    if org_domain.is_empty() {
880        return Classification {
881            standard: "poppy".into(),
882            status: Validity::Invalid,
883            summary: Value::Null,
884            reason: Some("missing organization.domain".into()),
885        };
886    }
887    if let Some(host) = origin_domain {
888        if !org_domain.is_empty() {
889            let org_lower = org_domain.to_ascii_lowercase();
890            let org_stripped = strip_www(&org_lower);
891            let host_stripped = strip_www(host);
892            let org_reg = registrable_domain(org_stripped);
893            let host_reg = registrable_domain(host_stripped);
894            let mismatch = match (org_reg, host_reg) {
895                (Some(o), Some(h)) => o != h,
896                _ => org_stripped != host_stripped,
897            };
898            if mismatch {
899                return Classification {
900                    standard: "poppy".into(),
901                    status: Validity::Invalid,
902                    summary: Value::Null,
903                    reason: Some(format!(
904                        "organization.domain {org_domain} does not match host {host}"
905                    )),
906                };
907            }
908        }
909    }
910
911    let has_agent = value.get("agent").is_some();
912    let has_apis = value.get("apis").is_some();
913    let has_web = value.get("web").is_some();
914    if !has_agent && !has_apis && !has_web {
915        return Classification {
916            standard: "poppy".into(),
917            status: Validity::Invalid,
918            summary: Value::Null,
919            reason: Some("requires at least one of agent, apis, web".into()),
920        };
921    }
922
923    let has_browser_session = value
924        .get("web")
925        .and_then(|w| w.get("browser_session_endpoint"))
926        .and_then(|v| v.as_str())
927        .is_some();
928    let needs_auth = has_agent || has_apis || has_browser_session;
929    if needs_auth && value.get("auth").is_none() {
930        return Classification {
931            standard: "poppy".into(),
932            status: Validity::Invalid,
933            summary: Value::Null,
934            reason: Some("auth required when agent, apis, or web.browser_session_endpoint is present".into()),
935        };
936    }
937
938    let org_name = org
939        .and_then(|o| o.get("name"))
940        .and_then(|n| n.as_str())
941        .map(|s| truncate(s, TITLE_CAP));
942
943    let issuer = value.get("issuer").and_then(|v| v.as_str()).map(str::to_string);
944
945    let sign_in: Vec<String> = value
946        .get("auth")
947        .and_then(|a| a.get("sign_in_methods"))
948        .and_then(|v| v.as_array())
949        .map(|arr| {
950            arr.iter()
951                .filter_map(|m| m.get("type").and_then(|t| t.as_str()).map(str::to_string))
952                .collect()
953        })
954        .unwrap_or_default();
955
956    let apis: Vec<Value> = value
957        .get("apis")
958        .and_then(|a| a.as_array())
959        .map(|arr| {
960            arr.iter()
961                .filter_map(|entry| {
962                    let t = entry.get("type").and_then(|v| v.as_str())?;
963                    let url = entry.get("url").and_then(|v| v.as_str())?;
964                    Some(serde_json::json!({ "type": t, "url": url }))
965                })
966                .collect()
967        })
968        .unwrap_or_default();
969
970    let agent_protocols: Vec<Value> = value
971        .get("agent")
972        .and_then(|a| a.get("protocols"))
973        .and_then(|p| p.as_array())
974        .map(|arr| {
975            arr.iter()
976                .filter_map(|entry| {
977                    let t = entry.get("type").and_then(|v| v.as_str())?;
978                    let ep = entry.get("endpoint").and_then(|v| v.as_str())?;
979                    Some(serde_json::json!({ "type": t, "endpoint": ep }))
980                })
981                .collect()
982        })
983        .unwrap_or_default();
984
985    let browser_session_endpoint = value
986        .get("web")
987        .and_then(|w| w.get("browser_session_endpoint"))
988        .and_then(|v| v.as_str())
989        .map(str::to_string);
990
991    let mut summary = serde_json::Map::new();
992    summary.insert("protocolVersion".into(), Value::String(protocol_version.to_string()));
993    if let Some(name) = org_name {
994        let mut org_map = serde_json::Map::new();
995        org_map.insert("name".into(), Value::String(name));
996        org_map.insert("domain".into(), Value::String(org_domain.to_string()));
997        summary.insert("organization".into(), Value::Object(org_map));
998    } else if !org_domain.is_empty() {
999        let mut org_map = serde_json::Map::new();
1000        org_map.insert("domain".into(), Value::String(org_domain.to_string()));
1001        summary.insert("organization".into(), Value::Object(org_map));
1002    }
1003    if let Some(iss) = issuer {
1004        summary.insert("issuer".into(), Value::String(iss));
1005    }
1006    if !sign_in.is_empty() {
1007        summary.insert("signIn".into(), Value::Array(
1008            sign_in.into_iter().map(Value::String).collect(),
1009        ));
1010    }
1011    if !apis.is_empty() {
1012        summary.insert("apis".into(), Value::Array(apis));
1013    }
1014    if !agent_protocols.is_empty() {
1015        summary.insert("agentProtocols".into(), Value::Array(agent_protocols));
1016    }
1017    if let Some(bse) = browser_session_endpoint {
1018        summary.insert("browserSessionEndpoint".into(), Value::String(bse));
1019    }
1020
1021    Classification {
1022        standard: "poppy".into(),
1023        status: Validity::Valid,
1024        summary: Value::Object(summary),
1025        reason: None,
1026    }
1027}
1028
1029const NLWEB_ASK_TYPE: &str = "application/nlweb+json";
1030
1031pub fn detect_nlweb_entries(summary: &Value) -> Option<Value> {
1032    let entries = summary.get("entries").and_then(|v| v.as_array())?;
1033    let mut ask_url: Option<String> = None;
1034    let mut mcp_url: Option<String> = None;
1035    let mut generic_url: Option<String> = None;
1036    for entry in entries {
1037        let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
1038        let url = entry.get("url").and_then(|v| v.as_str()).unwrap_or("");
1039        let id_lower = entry.get("identifier").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
1040        let name_lower = entry.get("displayName").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
1041        let type_lower = entry_type.to_ascii_lowercase();
1042        let is_nlweb = type_lower.contains("nlweb")
1043            || id_lower.contains("nlweb")
1044            || name_lower.contains("nlweb");
1045        if !is_nlweb {
1046            continue;
1047        }
1048        if url.is_empty() {
1049            continue;
1050        }
1051        let explicit_ask = type_lower == NLWEB_ASK_TYPE || url.contains("/ask");
1052        let explicit_mcp = url.contains("/mcp");
1053        if explicit_ask && ask_url.is_none() {
1054            ask_url = Some(url.to_string());
1055        }
1056        if explicit_mcp && mcp_url.is_none() {
1057            mcp_url = Some(url.to_string());
1058        }
1059        if !explicit_ask && !explicit_mcp && generic_url.is_none() {
1060            generic_url = Some(url.to_string());
1061        }
1062    }
1063    if ask_url.is_none() && mcp_url.is_none() {
1064        ask_url = Some(generic_url?);
1065    }
1066    let mut map = serde_json::Map::new();
1067    if let Some(ref u) = ask_url {
1068        map.insert("askUrl".into(), Value::String(u.clone()));
1069    }
1070    if let Some(ref u) = mcp_url {
1071        map.insert("mcpUrl".into(), Value::String(u.clone()));
1072    }
1073    Some(Value::Object(map))
1074}
1075
1076pub fn classify_nlweb_ask(body: &str) -> Option<Value> {
1077    let parsed: Value = serde_json::from_str(body).ok()?;
1078    let is_nlweb_shape = parsed.get("response").is_some()
1079        || parsed.get("results").is_some()
1080        || parsed.get("answer").is_some();
1081    if !is_nlweb_shape {
1082        return None;
1083    }
1084    let mut map = serde_json::Map::new();
1085    if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) {
1086        map.insert("serverName".into(), Value::String(truncate(name, TITLE_CAP)));
1087    }
1088    Some(Value::Object(map))
1089}
1090
1091pub fn classify_nlweb_mcp_init(body: &str) -> Option<Value> {
1092    let parsed: Value = serde_json::from_str(body).ok()?;
1093    let server_info = parsed.get("result")
1094        .and_then(|r| r.get("serverInfo"));
1095    let server_name = server_info
1096        .and_then(|s| s.get("name"))
1097        .and_then(|n| n.as_str())
1098        .unwrap_or("");
1099    if server_name.to_ascii_lowercase().contains("nlweb") {
1100        let mut map = serde_json::Map::new();
1101        let mut si = serde_json::Map::new();
1102        si.insert("name".into(), Value::String(truncate(server_name, TITLE_CAP)));
1103        if let Some(version) = server_info.and_then(|s| s.get("version")).and_then(|v| v.as_str()) {
1104            si.insert("version".into(), Value::String(truncate(version, TITLE_CAP)));
1105        }
1106        map.insert("serverInfo".into(), Value::Object(si));
1107        return Some(Value::Object(map));
1108    }
1109    None
1110}
1111
1112fn truncate(s: &str, max: usize) -> String {
1113    if s.len() <= max {
1114        s.to_string()
1115    } else {
1116        let mut end = max;
1117        while !s.is_char_boundary(end) && end > 0 {
1118            end -= 1;
1119        }
1120        s[..end].to_string()
1121    }
1122}
1123
1124pub fn is_slicc_handoff_rel(rel: &str) -> bool {
1125    rel == HANDOFF_REL || rel == UPSKILL_REL
1126}
1127
1128pub fn classify_slicc_handoff(link: &crate::link::Link) -> Option<Value> {
1129    let verb = if link.rel == HANDOFF_REL {
1130        "handoff"
1131    } else if link.rel == UPSKILL_REL {
1132        "upskill"
1133    } else {
1134        return None;
1135    };
1136
1137    let mut map = serde_json::Map::new();
1138    map.insert("verb".into(), Value::String(verb.into()));
1139    map.insert("rel".into(), Value::String(link.rel.clone()));
1140    map.insert("href".into(), Value::String(link.href.clone()));
1141    if let Some(ref t) = link.media_type {
1142        map.insert("type".into(), Value::String(t.clone()));
1143    }
1144    if let Some(ref title) = link.title {
1145        if !title.is_empty() {
1146            map.insert("instruction".into(), Value::String(truncate(title, TITLE_CAP)));
1147        }
1148    }
1149
1150    if verb == "upskill" {
1151        if let Some(branch) = link.params.get("branch") {
1152            if is_safe_branch(branch) {
1153                map.insert("branch".into(), Value::String(branch.clone()));
1154            }
1155        }
1156        if let Some(path) = link.params.get("path") {
1157            let canon = canonicalise_path(path);
1158            if is_safe_path(&canon) {
1159                map.insert("path".into(), Value::String(canon));
1160            }
1161        }
1162    }
1163
1164    Some(Value::Object(map))
1165}
1166
1167fn canonicalise_path(raw: &str) -> String {
1168    let trimmed = raw.trim_end_matches('/');
1169    let lower = trimmed.to_ascii_lowercase();
1170    if lower.ends_with("/skill.md") {
1171        trimmed[..trimmed.len() - "/skill.md".len()].to_string()
1172    } else if lower == "skill.md" {
1173        String::new()
1174    } else {
1175        trimmed.to_string()
1176    }
1177}
1178
1179fn is_safe_branch(value: &str) -> bool {
1180    if value.is_empty() || value.len() > 250 {
1181        return false;
1182    }
1183    if !value.bytes().all(|b| b.is_ascii_alphanumeric() || b"._/-".contains(&b)) {
1184        return false;
1185    }
1186    if value.starts_with('-') || value.starts_with('/') || value.ends_with('/') {
1187        return false;
1188    }
1189    if value.contains("..") || value.ends_with(".lock") {
1190        return false;
1191    }
1192    true
1193}
1194
1195fn is_safe_path(value: &str) -> bool {
1196    if value.is_empty() || value.len() > 1024 {
1197        return false;
1198    }
1199    if !value.bytes().all(|b| b.is_ascii_alphanumeric() || b"._/-".contains(&b)) {
1200        return false;
1201    }
1202    if value.starts_with('-') || value.starts_with('/') {
1203        return false;
1204    }
1205    if value.contains("..") {
1206        return false;
1207    }
1208    true
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214
1215    #[test]
1216    fn known_link_rels_includes_ard() {
1217        assert!(known_link_rels().contains(&"ard"));
1218    }
1219
1220    #[test]
1221    fn known_link_rels_includes_ai_catalog() {
1222        assert!(known_link_rels().contains(&"ai-catalog"));
1223    }
1224
1225    #[test]
1226    fn known_link_rels_includes_rfc_rels() {
1227        for rel in &["service-desc", "service-meta", "status", "service-doc", "api-catalog"] {
1228            assert!(known_link_rels().contains(rel), "missing {rel}");
1229        }
1230    }
1231
1232    #[test]
1233    fn known_link_rels_includes_llms_txt_rel() {
1234        assert!(known_link_rels().contains(&LLMS_TXT_REL));
1235    }
1236
1237    #[test]
1238    fn known_link_rels_includes_slicc_rels() {
1239        assert!(known_link_rels().contains(&HANDOFF_REL));
1240        assert!(known_link_rels().contains(&UPSKILL_REL));
1241    }
1242
1243    #[test]
1244    fn default_probes_has_ard() {
1245        let registry = default_probes();
1246        let probes = registry.probes_for_origin("https://example.com");
1247        assert!(probes.iter().any(|p| p.probe.standard == "ard"));
1248        assert!(probes.iter().any(|p| p.url == "https://example.com/.well-known/ard.json"));
1249    }
1250
1251    #[test]
1252    fn default_probes_has_api_catalog() {
1253        let registry = default_probes();
1254        let probes = registry.probes_for_origin("https://example.com");
1255        let ac = probes.iter().find(|p| p.probe.standard == "api-catalog").unwrap();
1256        assert_eq!(ac.url, "https://example.com/.well-known/api-catalog");
1257    }
1258
1259    #[test]
1260    fn default_probes_has_ai_catalog() {
1261        let registry = default_probes();
1262        let probes = registry.probes_for_origin("https://example.com");
1263        let aic = probes.iter().find(|p| p.probe.standard == "ai-catalog").unwrap();
1264        assert_eq!(aic.url, "https://example.com/.well-known/ai-catalog.json");
1265    }
1266
1267    #[test]
1268    fn default_probes_has_llms_txt() {
1269        let registry = default_probes();
1270        let probes = registry.probes_for_origin("https://example.com");
1271        let lt = probes.iter().find(|p| p.probe.standard == "llms-txt").unwrap();
1272        assert_eq!(lt.url, "https://example.com/llms.txt");
1273    }
1274
1275    #[test]
1276    fn default_probes_has_poppy() {
1277        let registry = default_probes();
1278        let probes = registry.probes_for_origin("https://example.com");
1279        let p = probes.iter().find(|p| p.probe.standard == "poppy").unwrap();
1280        assert_eq!(p.url, "https://example.com/.well-known/poppy.json");
1281        assert_eq!(p.probe.accept, "application/json");
1282    }
1283
1284    #[test]
1285    fn default_classifiers_has_ard() {
1286        let registry = default_classifiers();
1287        let result = registry.classify("ard", r#"{"name":"Test"}"#).unwrap();
1288        assert_eq!(result.status, Validity::Valid);
1289        assert_eq!(result.summary["name"], "Test");
1290    }
1291
1292    #[test]
1293    fn default_classifiers_has_api_catalog() {
1294        let registry = default_classifiers();
1295        let result = registry.classify("api-catalog", r#"{"linkset":[{"anchor":"/"}]}"#).unwrap();
1296        assert_eq!(result.status, Validity::Valid);
1297        assert_eq!(result.summary["format"], "linkset");
1298    }
1299
1300    #[test]
1301    fn default_classifiers_has_ai_catalog() {
1302        let registry = default_classifiers();
1303        let result = registry.classify("ai-catalog", r#"{"name":"Test Catalog","entries":[{"identifier":"urn:air:example.com:mcp","displayName":"MCP Server","type":"application/mcp-server-card+json","url":"https://example.com/mcp"}]}"#).unwrap();
1304        assert_eq!(result.status, Validity::Valid);
1305        assert_eq!(result.standard, "ai-catalog");
1306        assert_eq!(result.summary["name"], "Test Catalog");
1307    }
1308
1309    #[test]
1310    fn ai_catalog_classifier_invalid_json() {
1311        let registry = default_classifiers();
1312        let result = registry.classify("ai-catalog", "not json").unwrap();
1313        assert_eq!(result.status, Validity::Invalid);
1314        assert_eq!(result.standard, "ai-catalog");
1315        assert!(result.reason.is_some());
1316    }
1317
1318    #[test]
1319    fn ai_catalog_body_with_origin() {
1320        let body = r#"{"name":"Cat","entries":[{"identifier":"urn:air:example.com:agent","displayName":"Agent","type":"application/a2a-agent-card+json","url":"https://example.com/agent"},{"identifier":"urn:air:example.com:ds","displayName":"Dataset","type":"application/dataset+json","url":"https://example.com/ds"},{"identifier":"urn:air:evil.com:mcp","displayName":"Evil","type":"application/mcp-server-card+json","url":"https://evil.com/mcp"}]}"#;
1321        let c = classify_ai_catalog_body(body, Some("https://example.com"));
1322        assert_eq!(c.standard, "ai-catalog");
1323        assert_eq!(c.status, Validity::Valid);
1324        let entries = c.summary["entries"].as_array().unwrap();
1325        assert_eq!(entries.len(), 2);
1326        let warnings = c.summary["warnings"].as_array().unwrap();
1327        assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("evil.com")));
1328        let types = c.summary["types"].as_object().unwrap();
1329        assert!(types.contains_key("application/a2a-agent-card+json"));
1330        assert!(types.contains_key("application/dataset+json"));
1331    }
1332
1333    #[test]
1334    fn ai_catalog_nested_catalog_entries() {
1335        let body = r#"{"name":"Root","entries":[{"identifier":"urn:air:example.com:cat","displayName":"Sub","type":"application/ai-catalog+json","url":"https://example.com/sub.json"}]}"#;
1336        let c = classify_ai_catalog_body(body, Some("https://example.com"));
1337        assert_eq!(c.standard, "ai-catalog");
1338        let catalogs = c.summary["catalogs"].as_array().unwrap();
1339        assert_eq!(catalogs.len(), 1);
1340        assert_eq!(catalogs[0]["url"], "https://example.com/sub.json");
1341    }
1342
1343    #[test]
1344    fn api_catalog_invalid_json() {
1345        let registry = default_classifiers();
1346        let result = registry.classify("api-catalog", "not json").unwrap();
1347        assert_eq!(result.status, Validity::Invalid);
1348        assert!(result.reason.is_some());
1349    }
1350
1351    #[test]
1352    fn default_classifiers_has_llms_txt() {
1353        let registry = default_classifiers();
1354        let result = registry.classify("llms-txt", "# Example\n> A summary\n## Section One\n## Section Two\n").unwrap();
1355        assert_eq!(result.status, Validity::Valid);
1356        assert_eq!(result.summary["title"], "Example");
1357        assert_eq!(result.summary["summary"], "A summary");
1358        let headings = result.summary["headings"].as_array().unwrap();
1359        assert_eq!(headings.len(), 2);
1360        assert_eq!(headings[0], "Section One");
1361        assert_eq!(headings[1], "Section Two");
1362    }
1363
1364    #[test]
1365    fn default_classifiers_has_poppy() {
1366        let registry = default_classifiers();
1367        let body = serde_json::json!({
1368            "protocol_version": "0.1",
1369            "organization": { "name": "Test", "domain": "example.com" },
1370            "web": { "home": "/" }
1371        }).to_string();
1372        let result = registry.classify("poppy", &body).unwrap();
1373        assert_eq!(result.status, Validity::Valid);
1374        assert_eq!(result.summary["protocolVersion"], "0.1");
1375    }
1376
1377    #[test]
1378    fn classify_ard_invalid_json() {
1379        let registry = default_classifiers();
1380        let result = registry.classify("ard", "not json").unwrap();
1381        assert_eq!(result.status, Validity::Invalid);
1382        assert!(result.reason.is_some());
1383    }
1384
1385    #[test]
1386    fn classify_response_leaves_402_to_the_payment_observer() {
1387        let headers = vec![("X-Payment".into(), "required".into())];
1388        assert!(classify_response(402, &headers, None).is_empty());
1389    }
1390
1391    #[test]
1392    fn classify_response_200_no_signal() {
1393        let signals = classify_response(200, &[], None);
1394        assert!(signals.is_empty());
1395    }
1396
1397    #[test]
1398    fn classify_response_401_emits_signal() {
1399        let signals = classify_response(401, &[], None);
1400        assert_eq!(signals.len(), 1);
1401        assert_eq!(signals[0].standard, "auth-md");
1402    }
1403
1404    #[test]
1405    fn llms_txt_summary_h1_only() {
1406        let summary = parse_llms_txt_summary("# My Site\nSome body text\n");
1407        assert_eq!(summary["title"], "My Site");
1408        assert!(summary.get("summary").is_none());
1409        assert!(summary.get("headings").is_none());
1410    }
1411
1412    #[test]
1413    fn llms_txt_summary_blockquote() {
1414        let summary = parse_llms_txt_summary("# Title\n> This is the summary\n> continued\nBody\n");
1415        assert_eq!(summary["title"], "Title");
1416        assert_eq!(summary["summary"], "This is the summary continued");
1417    }
1418
1419    #[test]
1420    fn llms_txt_summary_headings_capped() {
1421        let mut body = "# Title\n".to_string();
1422        for i in 0..60 {
1423            body.push_str(&format!("## Heading {i}\n"));
1424        }
1425        let summary = parse_llms_txt_summary(&body);
1426        let headings = summary["headings"].as_array().unwrap();
1427        assert_eq!(headings.len(), 50);
1428    }
1429
1430    #[test]
1431    fn llms_txt_summary_title_truncated() {
1432        let long_title = format!("# {}", "x".repeat(300));
1433        let summary = parse_llms_txt_summary(&long_title);
1434        assert!(summary["title"].as_str().unwrap().len() <= 200);
1435    }
1436
1437    #[test]
1438    fn llms_txt_body_not_in_summary() {
1439        let body = "# Title\n> Summary\n## Sec\nThis is body text that should not appear\n";
1440        let summary = parse_llms_txt_summary(body);
1441        let json = serde_json::to_string(&summary).unwrap();
1442        assert!(!json.contains("body text"));
1443        assert!(!json.contains("should not appear"));
1444    }
1445
1446    #[test]
1447    fn llms_txt_empty_body() {
1448        let summary = parse_llms_txt_summary("");
1449        assert!(summary.as_object().unwrap().is_empty());
1450    }
1451
1452    #[test]
1453    fn llmstxtignore_defaults() {
1454        let defaults = default_llmstxtignore_entries();
1455        assert!(defaults.contains(&"github.com"));
1456        assert!(defaults.contains(&"app.slack.com"));
1457    }
1458
1459    #[test]
1460    fn slicc_handoff_upskill_rel() {
1461        use crate::link::Link;
1462        let link = Link {
1463            href: "https://github.com/o/r".into(),
1464            rel: UPSKILL_REL.to_string(),
1465            media_type: None,
1466            title: None,
1467            params: [
1468                ("branch".to_string(), "main".to_string()),
1469                ("path".to_string(), "skills/foo".to_string()),
1470            ].into_iter().collect(),
1471        };
1472        let summary = classify_slicc_handoff(&link).unwrap();
1473        assert_eq!(summary["verb"], "upskill");
1474        assert_eq!(summary["branch"], "main");
1475        assert_eq!(summary["path"], "skills/foo");
1476    }
1477
1478    #[test]
1479    fn slicc_handoff_rel() {
1480        use crate::link::Link;
1481        let link = Link {
1482            href: "https://example.com/page".into(),
1483            rel: HANDOFF_REL.to_string(),
1484            media_type: None,
1485            title: Some("Continue the signup flow".into()),
1486            params: Default::default(),
1487        };
1488        let summary = classify_slicc_handoff(&link).unwrap();
1489        assert_eq!(summary["verb"], "handoff");
1490        assert_eq!(summary["instruction"], "Continue the signup flow");
1491        assert!(summary.get("branch").is_none());
1492    }
1493
1494    #[test]
1495    fn slicc_handoff_ignores_branch_path() {
1496        use crate::link::Link;
1497        let link = Link {
1498            href: "https://example.com/page".into(),
1499            rel: HANDOFF_REL.to_string(),
1500            media_type: None,
1501            title: Some("x".into()),
1502            params: [
1503                ("branch".to_string(), "main".to_string()),
1504                ("path".to_string(), "skills/foo".to_string()),
1505            ].into_iter().collect(),
1506        };
1507        let summary = classify_slicc_handoff(&link).unwrap();
1508        assert!(summary.get("branch").is_none());
1509        assert!(summary.get("path").is_none());
1510    }
1511
1512    #[test]
1513    fn slicc_upskill_strips_skill_md() {
1514        use crate::link::Link;
1515        let link = Link {
1516            href: "https://github.com/o/r".into(),
1517            rel: UPSKILL_REL.to_string(),
1518            media_type: None,
1519            title: None,
1520            params: [
1521                ("path".to_string(), "skills/foo/SKILL.md".to_string()),
1522            ].into_iter().collect(),
1523        };
1524        let summary = classify_slicc_handoff(&link).unwrap();
1525        assert_eq!(summary["path"], "skills/foo");
1526    }
1527
1528    #[test]
1529    fn slicc_upskill_drops_unsafe_branch() {
1530        use crate::link::Link;
1531        for branch in &["main;rm -rf /", "main`whoami`", "main$(whoami)", "-rf", "main\necho PWNED"] {
1532            let link = Link {
1533                href: "https://github.com/o/r".into(),
1534                rel: UPSKILL_REL.to_string(),
1535                media_type: None,
1536                title: None,
1537                params: [("branch".to_string(), branch.to_string())].into_iter().collect(),
1538            };
1539            let summary = classify_slicc_handoff(&link).unwrap();
1540            assert!(summary.get("branch").is_none(), "branch {branch:?} should be dropped");
1541        }
1542    }
1543
1544    #[test]
1545    fn slicc_upskill_drops_unsafe_path() {
1546        use crate::link::Link;
1547        for path in &["../etc/passwd", "/etc/passwd", "-rf", "skills/foo;rm", "skills/`id`", "skills/$(id)"] {
1548            let link = Link {
1549                href: "https://github.com/o/r".into(),
1550                rel: UPSKILL_REL.to_string(),
1551                media_type: None,
1552                title: None,
1553                params: [("path".to_string(), path.to_string())].into_iter().collect(),
1554            };
1555            let summary = classify_slicc_handoff(&link).unwrap();
1556            assert!(summary.get("path").is_none(), "path {path:?} should be dropped");
1557        }
1558    }
1559
1560    #[test]
1561    fn slicc_upskill_keeps_safe_branch() {
1562        use crate::link::Link;
1563        let link = Link {
1564            href: "https://github.com/o/r".into(),
1565            rel: UPSKILL_REL.to_string(),
1566            media_type: None,
1567            title: None,
1568            params: [("branch".to_string(), "release/v1.2_hotfix-3".to_string())].into_iter().collect(),
1569        };
1570        let summary = classify_slicc_handoff(&link).unwrap();
1571        assert_eq!(summary["branch"], "release/v1.2_hotfix-3");
1572    }
1573
1574    #[test]
1575    fn is_slicc_handoff_rel_recognises_both() {
1576        assert!(is_slicc_handoff_rel(HANDOFF_REL));
1577        assert!(is_slicc_handoff_rel(UPSKILL_REL));
1578        assert!(!is_slicc_handoff_rel("ard"));
1579    }
1580
1581    #[test]
1582    fn slicc_upskill_drops_only_skill_md_path() {
1583        use crate::link::Link;
1584        let link = Link {
1585            href: "https://github.com/o/r".into(),
1586            rel: UPSKILL_REL.to_string(),
1587            media_type: None,
1588            title: None,
1589            params: [("path".to_string(), "SKILL.md".to_string())].into_iter().collect(),
1590        };
1591        let summary = classify_slicc_handoff(&link).unwrap();
1592        assert!(summary.get("path").is_none());
1593    }
1594
1595    #[test]
1596    fn slicc_unrecognised_rel_returns_none() {
1597        use crate::link::Link;
1598        let link = Link {
1599            href: "https://example.com/".into(),
1600            rel: "next".into(),
1601            media_type: None,
1602            title: None,
1603            params: Default::default(),
1604        };
1605        assert!(classify_slicc_handoff(&link).is_none());
1606    }
1607
1608    #[test]
1609    fn ard_valid_entries_grouped_by_type() {
1610        let body = serde_json::json!({
1611            "name": "Acme Corp",
1612            "entries": [
1613                {
1614                    "identifier": "urn:air:acme.com:server:weather",
1615                    "displayName": "Weather Server",
1616                    "type": "application/mcp-server-card+json",
1617                    "url": "https://api.acme.com/mcp/weather.json",
1618                    "capabilities": ["WeatherTool"],
1619                    "representativeQueries": ["what is the weather"]
1620                },
1621                {
1622                    "identifier": "urn:air:acme.com:skill:pptx",
1623                    "displayName": "PPTX Creator",
1624                    "type": "application/agent-skills+md",
1625                    "url": "https://acme.com/skills/pptx"
1626                }
1627            ]
1628        }).to_string();
1629        let result = classify_ard_body(&body, Some("https://acme.com"));
1630        assert_eq!(result.status, Validity::Valid);
1631        assert_eq!(result.summary["name"], "Acme Corp");
1632        let entries = result.summary["entries"].as_array().unwrap();
1633        assert_eq!(entries.len(), 2);
1634        assert_eq!(entries[0]["type"], "application/mcp-server-card+json");
1635        assert_eq!(entries[1]["type"], "application/agent-skills+md");
1636        let types = result.summary["types"].as_object().unwrap();
1637        assert_eq!(types["application/mcp-server-card+json"], 1);
1638        assert_eq!(types["application/agent-skills+md"], 1);
1639    }
1640
1641    #[test]
1642    fn ard_squatted_urn_produces_warning() {
1643        let body = serde_json::json!({
1644            "name": "Evil",
1645            "entries": [
1646                {
1647                    "identifier": "urn:air:google.com:server:search",
1648                    "displayName": "Fake Google",
1649                    "type": "application/mcp-server-card+json",
1650                    "url": "https://evil.com/fake"
1651                },
1652                {
1653                    "identifier": "urn:air:evil.com:server:legit",
1654                    "displayName": "Legit Server",
1655                    "type": "application/mcp-server-card+json",
1656                    "url": "https://evil.com/legit"
1657                }
1658            ]
1659        }).to_string();
1660        let result = classify_ard_body(&body, Some("https://evil.com"));
1661        assert_eq!(result.status, Validity::Valid);
1662        let entries = result.summary["entries"].as_array().unwrap();
1663        assert_eq!(entries.len(), 1);
1664        assert_eq!(entries[0]["displayName"], "Legit Server");
1665        let warnings = result.summary["warnings"].as_array().unwrap();
1666        assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("google.com")));
1667    }
1668
1669    #[test]
1670    fn ard_trust_identity_mismatch_warns() {
1671        let body = serde_json::json!({
1672            "name": "Test",
1673            "entries": [
1674                {
1675                    "identifier": "urn:air:example.com:server:api",
1676                    "displayName": "API",
1677                    "type": "application/mcp-server-card+json",
1678                    "url": "https://example.com/api",
1679                    "trustManifest": {
1680                        "identity": "other-corp.com"
1681                    }
1682                }
1683            ]
1684        }).to_string();
1685        let result = classify_ard_body(&body, Some("https://example.com"));
1686        let warnings = result.summary["warnings"].as_array().unwrap();
1687        assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("trustManifest.identity")));
1688        let entries = result.summary["entries"].as_array().unwrap();
1689        assert_eq!(entries.len(), 1);
1690    }
1691
1692    #[test]
1693    fn ard_nested_catalogs_reported() {
1694        let body = serde_json::json!({
1695            "name": "Hub",
1696            "entries": [
1697                {
1698                    "identifier": "urn:air:hub.com:catalog:partners",
1699                    "displayName": "Partners Catalog",
1700                    "type": "application/ai-catalog+json",
1701                    "url": "https://hub.com/partners.json"
1702                },
1703                {
1704                    "identifier": "urn:air:hub.com:registry:search",
1705                    "displayName": "Search Registry",
1706                    "type": "application/ai-registry+json",
1707                    "url": "https://hub.com/search"
1708                }
1709            ]
1710        }).to_string();
1711        let result = classify_ard_body(&body, Some("https://hub.com"));
1712        let catalogs = result.summary["catalogs"].as_array().unwrap();
1713        assert_eq!(catalogs.len(), 2);
1714        assert_eq!(catalogs[0]["type"], "application/ai-catalog+json");
1715        assert_eq!(catalogs[1]["type"], "application/ai-registry+json");
1716    }
1717
1718    #[test]
1719    fn ard_missing_required_field_skipped() {
1720        let body = serde_json::json!({
1721            "name": "Bad",
1722            "entries": [
1723                {
1724                    "displayName": "No Identifier",
1725                    "type": "application/mcp-server-card+json",
1726                    "url": "https://bad.com/x"
1727                },
1728                {
1729                    "identifier": "urn:air:bad.com:x",
1730                    "type": "application/mcp-server-card+json",
1731                    "url": "https://bad.com/x"
1732                },
1733                {
1734                    "identifier": "urn:air:bad.com:y",
1735                    "displayName": "No URL or Data",
1736                    "type": "application/mcp-server-card+json"
1737                }
1738            ]
1739        }).to_string();
1740        let result = classify_ard_body(&body, Some("https://bad.com"));
1741        let entries = result.summary["entries"].as_array().unwrap();
1742        assert_eq!(entries.len(), 0);
1743        let warnings = result.summary["warnings"].as_array().unwrap();
1744        assert_eq!(warnings.len(), 3);
1745    }
1746
1747    #[test]
1748    fn ard_both_url_and_data_rejected() {
1749        let body = serde_json::json!({
1750            "name": "Both",
1751            "entries": [{
1752                "identifier": "urn:air:both.com:x",
1753                "displayName": "Both Fields",
1754                "type": "application/mcp-server-card+json",
1755                "url": "https://both.com/x",
1756                "data": {}
1757            }]
1758        }).to_string();
1759        let result = classify_ard_body(&body, Some("https://both.com"));
1760        let entries = result.summary["entries"].as_array().unwrap();
1761        assert_eq!(entries.len(), 0);
1762    }
1763
1764    #[test]
1765    fn ard_no_entries_array_warns() {
1766        let body = r#"{"name":"Empty"}"#;
1767        let result = classify_ard_body(body, None);
1768        assert_eq!(result.status, Validity::Valid);
1769        assert_eq!(result.summary["name"], "Empty");
1770        let warnings = result.summary["warnings"].as_array().unwrap();
1771        assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("no entries")));
1772    }
1773
1774    #[test]
1775    fn ard_invalid_json() {
1776        let result = classify_ard_body("not json", None);
1777        assert_eq!(result.status, Validity::Invalid);
1778        assert!(result.reason.is_some());
1779    }
1780
1781    #[test]
1782    fn ard_no_origin_skips_urn_check() {
1783        let body = serde_json::json!({
1784            "name": "No Origin",
1785            "entries": [{
1786                "identifier": "urn:air:google.com:server:search",
1787                "displayName": "Search",
1788                "type": "application/mcp-server-card+json",
1789                "url": "https://google.com/mcp"
1790            }]
1791        }).to_string();
1792        let result = classify_ard_body(&body, None);
1793        let entries = result.summary["entries"].as_array().unwrap();
1794        assert_eq!(entries.len(), 1);
1795    }
1796
1797    #[test]
1798    fn ard_subdomain_urn_matches_registrable() {
1799        let body = serde_json::json!({
1800            "name": "Sub",
1801            "entries": [{
1802                "identifier": "urn:air:acme.com:server:api",
1803                "displayName": "API",
1804                "type": "application/mcp-server-card+json",
1805                "url": "https://api.acme.com/mcp"
1806            }]
1807        }).to_string();
1808        let result = classify_ard_body(&body, Some("https://api.acme.com"));
1809        let entries = result.summary["entries"].as_array().unwrap();
1810        assert_eq!(entries.len(), 1);
1811        assert!(result.summary.get("warnings").is_none());
1812    }
1813
1814    #[test]
1815    fn ard_capabilities_and_queries_in_summary() {
1816        let body = serde_json::json!({
1817            "name": "Rich",
1818            "entries": [{
1819                "identifier": "urn:air:rich.com:server:api",
1820                "displayName": "API",
1821                "type": "application/mcp-server-card+json",
1822                "url": "https://rich.com/mcp",
1823                "capabilities": ["FooTool", "BarTool"],
1824                "representativeQueries": ["how do I foo", "bar something"]
1825            }]
1826        }).to_string();
1827        let result = classify_ard_body(&body, Some("https://rich.com"));
1828        let entry = &result.summary["entries"][0];
1829        let caps = entry["capabilities"].as_array().unwrap();
1830        assert_eq!(caps.len(), 2);
1831        let queries = entry["representativeQueries"].as_array().unwrap();
1832        assert_eq!(queries.len(), 2);
1833    }
1834
1835    #[test]
1836    fn ard_display_name_capped() {
1837        let long_name = "x".repeat(300);
1838        let body = serde_json::json!({
1839            "name": "Cap",
1840            "entries": [{
1841                "identifier": "urn:air:cap.com:x",
1842                "displayName": long_name,
1843                "type": "application/mcp-server-card+json",
1844                "url": "https://cap.com/x"
1845            }]
1846        }).to_string();
1847        let result = classify_ard_body(&body, Some("https://cap.com"));
1848        let entry = &result.summary["entries"][0];
1849        assert!(entry["displayName"].as_str().unwrap().len() <= 200);
1850    }
1851
1852    #[test]
1853    fn ard_data_entry_accepted() {
1854        let body = serde_json::json!({
1855            "name": "Inline",
1856            "entries": [{
1857                "identifier": "urn:air:inline.com:x",
1858                "displayName": "Inline",
1859                "type": "application/agent-skills+md",
1860                "data": "# My Skill\nSome content"
1861            }]
1862        }).to_string();
1863        let result = classify_ard_body(&body, Some("https://inline.com"));
1864        let entries = result.summary["entries"].as_array().unwrap();
1865        assert_eq!(entries.len(), 1);
1866    }
1867
1868    #[test]
1869    fn ard_fixture_mcp_skill_squatted() {
1870        let body = serde_json::json!({
1871            "name": "Fixture Site",
1872            "entries": [
1873                {
1874                    "identifier": "urn:air:fixture.com:server:weather",
1875                    "displayName": "Weather MCP",
1876                    "type": "application/mcp-server-card+json",
1877                    "url": "https://fixture.com/mcp/weather.json",
1878                    "capabilities": ["WeatherTool"]
1879                },
1880                {
1881                    "identifier": "urn:air:fixture.com:skill:pptx",
1882                    "displayName": "PPTX Skill",
1883                    "type": "application/agent-skills+md",
1884                    "url": "https://fixture.com/skills/pptx"
1885                },
1886                {
1887                    "identifier": "urn:air:google.com:server:search",
1888                    "displayName": "Squatted Google",
1889                    "type": "application/mcp-server-card+json",
1890                    "url": "https://fixture.com/fake-google"
1891                }
1892            ]
1893        }).to_string();
1894        let result = classify_ard_body(&body, Some("https://fixture.com"));
1895        assert_eq!(result.status, Validity::Valid);
1896        let entries = result.summary["entries"].as_array().unwrap();
1897        assert_eq!(entries.len(), 2);
1898        let types: Vec<&str> = entries.iter()
1899            .map(|e| e["type"].as_str().unwrap())
1900            .collect();
1901        assert!(types.contains(&"application/mcp-server-card+json"));
1902        assert!(types.contains(&"application/agent-skills+md"));
1903        let warnings = result.summary["warnings"].as_array().unwrap();
1904        assert!(warnings.iter().any(|w| {
1905            let s = w.as_str().unwrap();
1906            s.contains("google.com") && s.contains("URN publisher")
1907        }));
1908    }
1909
1910    #[test]
1911    fn robots_txt_agentmap_parses() {
1912        let robots = "User-agent: *\nDisallow: /private\nAgentmap: https://example.com/entries.json\n";
1913        let urls = parse_robots_txt_agentmap(robots);
1914        assert_eq!(urls, vec!["https://example.com/entries.json"]);
1915    }
1916
1917    #[test]
1918    fn robots_txt_agentmap_case_insensitive() {
1919        let robots = "AGENTMAP: https://example.com/a.json\nagentmap: https://example.com/b.json\n";
1920        let urls = parse_robots_txt_agentmap(robots);
1921        assert_eq!(urls.len(), 2);
1922    }
1923
1924    #[test]
1925    fn robots_txt_agentmap_empty() {
1926        let robots = "User-agent: *\nDisallow: /\n";
1927        let urls = parse_robots_txt_agentmap(robots);
1928        assert!(urls.is_empty());
1929    }
1930
1931    #[test]
1932    fn robots_txt_agentmap_multiple() {
1933        let robots = "Agentmap: https://a.com/1.json\nAgentmap: https://b.com/2.json\n";
1934        let urls = parse_robots_txt_agentmap(robots);
1935        assert_eq!(urls.len(), 2);
1936    }
1937
1938    #[test]
1939    fn registrable_domain_works() {
1940        assert_eq!(registrable_domain("www.example.com"), Some("example.com".into()));
1941        assert_eq!(registrable_domain("api.acme.co.uk"), Some("acme.co.uk".into()));
1942        assert_eq!(registrable_domain("example.com"), Some("example.com".into()));
1943    }
1944
1945    #[test]
1946    fn registrable_domain_tld_only() {
1947        assert_eq!(registrable_domain("com"), None);
1948    }
1949
1950    #[test]
1951    fn urn_check_cross_origin() {
1952        let warn = check_urn_publisher("urn:air:google.com:server:x", Some("evil.com"));
1953        assert!(warn.is_some());
1954    }
1955
1956    #[test]
1957    fn urn_check_same_origin() {
1958        let warn = check_urn_publisher("urn:air:acme.com:server:x", Some("acme.com"));
1959        assert!(warn.is_none());
1960    }
1961
1962    #[test]
1963    fn urn_check_subdomain_matches() {
1964        let warn = check_urn_publisher("urn:air:acme.com:server:x", Some("api.acme.com"));
1965        assert!(warn.is_none());
1966    }
1967
1968    #[test]
1969    fn urn_check_no_origin() {
1970        let warn = check_urn_publisher("urn:air:google.com:server:x", None);
1971        assert!(warn.is_none());
1972    }
1973
1974    #[test]
1975    fn urn_check_non_urn_identifier() {
1976        let warn = check_urn_publisher("some-other-id", Some("evil.com"));
1977        assert!(warn.is_none());
1978    }
1979
1980    #[test]
1981    fn trust_identity_matching() {
1982        let entry = serde_json::json!({
1983            "trustManifest": { "identity": "example.com" }
1984        });
1985        let warn = check_trust_identity(&entry, Some("example.com"));
1986        assert!(warn.is_none());
1987    }
1988
1989    #[test]
1990    fn trust_identity_mismatch() {
1991        let entry = serde_json::json!({
1992            "trustManifest": { "identity": "other.com" }
1993        });
1994        let warn = check_trust_identity(&entry, Some("example.com"));
1995        assert!(warn.is_some());
1996    }
1997
1998    #[test]
1999    fn trust_identity_no_manifest() {
2000        let entry = serde_json::json!({});
2001        let warn = check_trust_identity(&entry, Some("example.com"));
2002        assert!(warn.is_none());
2003    }
2004
2005    #[test]
2006    fn trust_identity_subdomain() {
2007        let entry = serde_json::json!({
2008            "trustManifest": { "identity": "api.acme.com" }
2009        });
2010        let warn = check_trust_identity(&entry, Some("www.acme.com"));
2011        assert!(warn.is_none());
2012    }
2013
2014    #[test]
2015    fn poppy_valid_minimal() {
2016        let body = serde_json::json!({
2017            "protocol_version": "0.1",
2018            "organization": { "name": "Acme", "domain": "example.com" },
2019            "web": { "home": "https://example.com" }
2020        }).to_string();
2021        let result = classify_poppy_body(&body, Some("https://example.com"));
2022        assert_eq!(result.status, Validity::Valid);
2023        assert_eq!(result.summary["protocolVersion"], "0.1");
2024        assert_eq!(result.summary["organization"]["name"], "Acme");
2025    }
2026
2027    #[test]
2028    fn poppy_spec_example() {
2029        let body = serde_json::json!({
2030            "protocol_version": "0.1",
2031            "organization": {
2032                "name": "Acme Corp",
2033                "domain": "acme.com"
2034            },
2035            "issuer": "https://auth.acme.com",
2036            "auth": {
2037                "sign_in_methods": [
2038                    { "type": "direct" },
2039                    { "type": "device" }
2040                ]
2041            },
2042            "agent": {
2043                "protocols": [
2044                    { "type": "a2a", "endpoint": "https://api.acme.com/a2a" }
2045                ]
2046            },
2047            "apis": [
2048                { "type": "mcp", "url": "https://api.acme.com/mcp" },
2049                { "type": "rest", "url": "https://api.acme.com/v1" }
2050            ],
2051            "web": {
2052                "browser_session_endpoint": "https://acme.com/session"
2053            }
2054        }).to_string();
2055        let result = classify_poppy_body(&body, Some("https://acme.com"));
2056        assert_eq!(result.status, Validity::Valid);
2057        assert_eq!(result.summary["protocolVersion"], "0.1");
2058        assert_eq!(result.summary["organization"]["name"], "Acme Corp");
2059        assert_eq!(result.summary["issuer"], "https://auth.acme.com");
2060        let sign_in = result.summary["signIn"].as_array().unwrap();
2061        assert_eq!(sign_in.len(), 2);
2062        let apis = result.summary["apis"].as_array().unwrap();
2063        assert_eq!(apis.len(), 2);
2064        assert_eq!(apis[0]["type"], "mcp");
2065        let protocols = result.summary["agentProtocols"].as_array().unwrap();
2066        assert_eq!(protocols.len(), 1);
2067        assert_eq!(protocols[0]["type"], "a2a");
2068        assert_eq!(result.summary["browserSessionEndpoint"], "https://acme.com/session");
2069    }
2070
2071    #[test]
2072    fn poppy_missing_protocol_version() {
2073        let body = serde_json::json!({
2074            "organization": { "domain": "example.com" },
2075            "web": {}
2076        }).to_string();
2077        let result = classify_poppy_body(&body, Some("https://example.com"));
2078        assert_eq!(result.status, Validity::Invalid);
2079        assert!(result.reason.unwrap().contains("protocol_version"));
2080    }
2081
2082    #[test]
2083    fn poppy_invalid_protocol_version() {
2084        let body = serde_json::json!({
2085            "protocol_version": "1",
2086            "organization": { "domain": "example.com" },
2087            "web": {}
2088        }).to_string();
2089        let result = classify_poppy_body(&body, Some("https://example.com"));
2090        assert_eq!(result.status, Validity::Invalid);
2091        assert!(result.reason.unwrap().contains("invalid protocol_version"));
2092    }
2093
2094    #[test]
2095    fn poppy_domain_mismatch() {
2096        let body = serde_json::json!({
2097            "protocol_version": "0.1",
2098            "organization": { "domain": "evil.com" },
2099            "web": {}
2100        }).to_string();
2101        let result = classify_poppy_body(&body, Some("https://example.com"));
2102        assert_eq!(result.status, Validity::Invalid);
2103        assert!(result.reason.unwrap().contains("does not match"));
2104    }
2105
2106    #[test]
2107    fn poppy_domain_www_stripped() {
2108        let body = serde_json::json!({
2109            "protocol_version": "0.1",
2110            "organization": { "domain": "www.example.com" },
2111            "web": {}
2112        }).to_string();
2113        let result = classify_poppy_body(&body, Some("https://example.com"));
2114        assert_eq!(result.status, Validity::Valid);
2115    }
2116
2117    #[test]
2118    fn poppy_no_sections() {
2119        let body = serde_json::json!({
2120            "protocol_version": "0.1",
2121            "organization": { "domain": "example.com" }
2122        }).to_string();
2123        let result = classify_poppy_body(&body, Some("https://example.com"));
2124        assert_eq!(result.status, Validity::Invalid);
2125        assert!(result.reason.unwrap().contains("at least one of"));
2126    }
2127
2128    #[test]
2129    fn poppy_missing_org_domain() {
2130        let body = serde_json::json!({
2131            "protocol_version": "0.1",
2132            "web": {}
2133        }).to_string();
2134        let result = classify_poppy_body(&body, Some("https://example.com"));
2135        assert_eq!(result.status, Validity::Invalid);
2136        assert!(result.reason.unwrap().contains("organization.domain"));
2137    }
2138
2139    #[test]
2140    fn poppy_auth_required_with_agent() {
2141        let body = serde_json::json!({
2142            "protocol_version": "0.1",
2143            "organization": { "domain": "example.com" },
2144            "agent": { "protocols": [] }
2145        }).to_string();
2146        let result = classify_poppy_body(&body, Some("https://example.com"));
2147        assert_eq!(result.status, Validity::Invalid);
2148        assert!(result.reason.unwrap().contains("auth required"));
2149    }
2150
2151    #[test]
2152    fn poppy_auth_required_with_apis() {
2153        let body = serde_json::json!({
2154            "protocol_version": "0.1",
2155            "organization": { "domain": "example.com" },
2156            "apis": []
2157        }).to_string();
2158        let result = classify_poppy_body(&body, Some("https://example.com"));
2159        assert_eq!(result.status, Validity::Invalid);
2160        assert!(result.reason.unwrap().contains("auth required"));
2161    }
2162
2163    #[test]
2164    fn poppy_auth_required_with_browser_session() {
2165        let body = serde_json::json!({
2166            "protocol_version": "0.1",
2167            "organization": { "domain": "example.com" },
2168            "web": { "browser_session_endpoint": "https://example.com/session" }
2169        }).to_string();
2170        let result = classify_poppy_body(&body, Some("https://example.com"));
2171        assert_eq!(result.status, Validity::Invalid);
2172        assert!(result.reason.unwrap().contains("auth required"));
2173    }
2174
2175    #[test]
2176    fn poppy_web_only_no_auth_needed() {
2177        let body = serde_json::json!({
2178            "protocol_version": "0.1",
2179            "organization": { "domain": "example.com" },
2180            "web": { "home": "https://example.com" }
2181        }).to_string();
2182        let result = classify_poppy_body(&body, Some("https://example.com"));
2183        assert_eq!(result.status, Validity::Valid);
2184    }
2185
2186    #[test]
2187    fn poppy_invalid_json() {
2188        let result = classify_poppy_body("not json", Some("https://example.com"));
2189        assert_eq!(result.status, Validity::Invalid);
2190    }
2191
2192    #[test]
2193    fn poppy_no_origin() {
2194        let body = serde_json::json!({
2195            "protocol_version": "0.1",
2196            "organization": { "domain": "any.com" },
2197            "web": {}
2198        }).to_string();
2199        let result = classify_poppy_body(&body, None);
2200        assert_eq!(result.status, Validity::Valid);
2201    }
2202
2203    #[test]
2204    fn poppy_subdomain_matches() {
2205        let body = serde_json::json!({
2206            "protocol_version": "0.1",
2207            "organization": { "domain": "api.acme.com" },
2208            "web": {}
2209        }).to_string();
2210        let result = classify_poppy_body(&body, Some("https://www.acme.com"));
2211        assert_eq!(result.status, Validity::Valid);
2212    }
2213
2214    #[test]
2215    fn known_link_rels_includes_auth_rels() {
2216        let rels = known_link_rels();
2217        assert!(rels.contains(&"auth-md"));
2218        assert!(rels.contains(&"oauth-authorization-server"));
2219        assert!(rels.contains(&"oauth-protected-resource"));
2220    }
2221
2222    #[test]
2223    fn default_probes_has_auth_md() {
2224        let registry = default_probes();
2225        let probes = registry.probes_for_origin("https://example.com");
2226        assert!(probes.iter().any(|p| p.probe.standard == "auth-md"));
2227        assert!(probes.iter().any(|p| p.url == "https://example.com/auth.md"));
2228    }
2229
2230    #[test]
2231    fn classify_response_401_emits_auth_md() {
2232        let headers = vec![
2233            ("www-authenticate".into(), "Bearer realm=\"api\"".into()),
2234        ];
2235        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2236        assert_eq!(signals.len(), 1);
2237        assert_eq!(signals[0].standard, "auth-md");
2238        assert_eq!(signals[0].summary["status"], 401);
2239        let schemes = signals[0].summary["schemes"].as_array().unwrap();
2240        assert_eq!(schemes.len(), 1);
2241        assert_eq!(schemes[0]["scheme"], "Bearer");
2242        assert_eq!(schemes[0]["realm"], "api");
2243    }
2244
2245    #[test]
2246    fn classify_response_401_with_resource_metadata() {
2247        let headers = vec![
2248            ("www-authenticate".into(), "Bearer realm=\"api\", resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource\"".into()),
2249        ];
2250        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2251        let schemes = signals[0].summary["schemes"].as_array().unwrap();
2252        assert_eq!(schemes[0]["resource_metadata"], "https://api.example.com/.well-known/oauth-protected-resource");
2253    }
2254
2255    #[test]
2256    fn classify_response_401_with_as_uri() {
2257        let headers = vec![
2258            ("www-authenticate".into(), "Bearer realm=\"api\", as_uri=\"https://auth.example.com/.well-known/oauth-authorization-server\"".into()),
2259        ];
2260        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2261        let schemes = signals[0].summary["schemes"].as_array().unwrap();
2262        assert_eq!(schemes[0]["as_uri"], "https://auth.example.com/.well-known/oauth-authorization-server");
2263    }
2264
2265    #[test]
2266    fn classify_response_401_strips_userinfo_from_urls() {
2267        let headers = vec![
2268            ("www-authenticate".into(), "Bearer resource_metadata=\"https://user:pass@api.example.com/.well-known/oauth-protected-resource\"".into()),
2269        ];
2270        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2271        let schemes = signals[0].summary["schemes"].as_array().unwrap();
2272        let rm = schemes[0]["resource_metadata"].as_str().unwrap();
2273        assert!(!rm.contains("user"));
2274        assert!(!rm.contains("pass"));
2275    }
2276
2277    #[test]
2278    fn classify_response_401_challenge_capped_at_500() {
2279        let long_realm = "x".repeat(600);
2280        let header = format!("Bearer realm=\"{long_realm}\"");
2281        let headers = vec![
2282            ("www-authenticate".into(), header),
2283        ];
2284        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2285        let schemes = signals[0].summary["schemes"].as_array().unwrap();
2286        assert_eq!(schemes[0]["scheme"], "Bearer");
2287    }
2288
2289    #[test]
2290    fn classify_response_401_no_www_auth() {
2291        let headers: Vec<(String, String)> = vec![];
2292        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2293        assert_eq!(signals.len(), 1);
2294        assert_eq!(signals[0].standard, "auth-md");
2295        assert!(signals[0].summary.get("schemes").is_none());
2296    }
2297
2298    #[test]
2299    fn classify_response_401_with_auth_link() {
2300        let headers = vec![
2301            ("www-authenticate".into(), "Bearer realm=\"api\"".into()),
2302            ("link".into(), "</auth.md>; rel=\"auth-md\"".into()),
2303        ];
2304        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2305        let links = signals[0].summary["links"].as_array().unwrap();
2306        assert_eq!(links.len(), 1);
2307        assert_eq!(links[0]["rel"], "auth-md");
2308        assert_eq!(links[0]["href"], "https://api.example.com/auth.md");
2309    }
2310
2311    #[test]
2312    fn classify_response_401_with_oauth_link() {
2313        let headers = vec![
2314            ("link".into(), "<https://auth.example.com/.well-known/oauth-authorization-server>; rel=\"oauth-authorization-server\"".into()),
2315        ];
2316        let signals = classify_response(401, &headers, Some("https://api.example.com/data"));
2317        let links = signals[0].summary["links"].as_array().unwrap();
2318        assert_eq!(links[0]["rel"], "oauth-authorization-server");
2319    }
2320
2321    #[test]
2322    fn classify_response_200_no_auth_signal() {
2323        let headers = vec![
2324            ("www-authenticate".into(), "Bearer realm=\"api\"".into()),
2325        ];
2326        let signals = classify_response(200, &headers, None);
2327        assert!(signals.iter().all(|s| s.standard != "auth-md"));
2328    }
2329
2330    #[test]
2331    fn classify_auth_md_valid_markdown() {
2332        let body = "# Auth\n\n- OAuth 2.0 with PKCE\n- API Key for testing\n\nclaim_window: 7 days\n";
2333        let classifiers = default_classifiers();
2334        let c = classifiers.classify("auth-md", body).unwrap();
2335        assert_eq!(c.status, Validity::Valid);
2336        assert_eq!(c.summary["title"], "Auth");
2337        let methods = c.summary["methods"].as_array().unwrap();
2338        assert!(methods.len() >= 2);
2339        assert!(c.summary["claim_window"].as_str().is_some());
2340    }
2341
2342    #[test]
2343    fn classify_auth_md_rejects_html() {
2344        let body = "<html><body>not markdown</body></html>";
2345        let classifiers = default_classifiers();
2346        let c = classifiers.classify("auth-md", body).unwrap();
2347        assert_eq!(c.status, Validity::Invalid);
2348    }
2349
2350    #[test]
2351    fn classify_auth_md_extracts_oauth_metadata_url() {
2352        let body = "# Auth\n\nOAuth metadata: https://auth.example.com/.well-known/oauth-authorization-server\n";
2353        let classifiers = default_classifiers();
2354        let c = classifiers.classify("auth-md", body).unwrap();
2355        let urls = c.summary["oauth_metadata"].as_array().unwrap();
2356        assert_eq!(urls.len(), 1);
2357        assert!(urls[0].as_str().unwrap().contains("oauth-authorization-server"));
2358    }
2359
2360    #[test]
2361    fn auth_md_probe_content_type_accepted() {
2362        let probes = default_probes();
2363        for ct in &["text/plain", "text/plain; charset=utf-8", "text/markdown"] {
2364            let m = probes.check_response("auth-md", 200, ct, "# Auth".into(), "u".into());
2365            assert!(m.is_some(), "content-type {ct} should match auth-md probe");
2366        }
2367    }
2368
2369    #[test]
2370    fn auth_md_probe_html_rejected() {
2371        let probes = default_probes();
2372        let m = probes.check_response("auth-md", 200, "text/html", "<html>".into(), "u".into());
2373        assert!(m.is_none());
2374    }
2375
2376    #[test]
2377    fn parse_www_authenticate_multiple_schemes() {
2378        let headers = vec![
2379            ("www-authenticate".into(), "Basic realm=\"site\"".into()),
2380            ("www-authenticate".into(), "Bearer realm=\"api\"".into()),
2381        ];
2382        let schemes = parse_www_authenticate_headers(&headers);
2383        assert_eq!(schemes.len(), 2);
2384        assert_eq!(schemes[0]["scheme"], "Basic");
2385        assert_eq!(schemes[1]["scheme"], "Bearer");
2386    }
2387
2388    #[test]
2389    fn extract_auth_links_filters_non_auth_rels() {
2390        let headers = vec![
2391            ("link".into(), "</api>; rel=\"api-catalog\", </auth.md>; rel=\"auth-md\"".into()),
2392        ];
2393        let links = extract_auth_links(&headers, Some("https://example.com/"));
2394        assert_eq!(links.len(), 1);
2395        assert_eq!(links[0]["rel"], "auth-md");
2396    }
2397
2398    #[test]
2399    fn parse_auth_md_summary_empty() {
2400        let summary = parse_auth_md_summary("");
2401        assert!(summary.as_object().unwrap().is_empty());
2402    }
2403
2404    #[test]
2405    fn find_url_in_line_extracts_https() {
2406        let url = find_url_in_line("See https://example.com/path for details");
2407        assert_eq!(url, Some("https://example.com/path"));
2408    }
2409
2410    #[test]
2411    fn find_url_in_line_none_when_absent() {
2412        let url = find_url_in_line("No URL here");
2413        assert!(url.is_none());
2414    }
2415
2416    #[test]
2417    fn detect_nlweb_entries_found() {
2418        let summary = serde_json::json!({
2419            "name": "Test",
2420            "entries": [
2421                {
2422                    "identifier": "urn:nlweb:example",
2423                    "displayName": "NLWeb Search",
2424                    "type": "application/nlweb+json",
2425                    "url": "https://example.com/ask"
2426                }
2427            ]
2428        });
2429        let result = detect_nlweb_entries(&summary);
2430        assert!(result.is_some());
2431        let val = result.unwrap();
2432        assert_eq!(val.get("askUrl").and_then(|v| v.as_str()), Some("https://example.com/ask"));
2433    }
2434
2435    #[test]
2436    fn detect_nlweb_entries_mcp() {
2437        let summary = serde_json::json!({
2438            "entries": [
2439                {
2440                    "identifier": "urn:nlweb:mcp",
2441                    "displayName": "NLWeb MCP",
2442                    "type": "application/mcp",
2443                    "url": "https://example.com/mcp"
2444                }
2445            ]
2446        });
2447        let result = detect_nlweb_entries(&summary);
2448        assert!(result.is_some());
2449        let val = result.unwrap();
2450        assert_eq!(val.get("mcpUrl").and_then(|v| v.as_str()), Some("https://example.com/mcp"));
2451    }
2452
2453    #[test]
2454    fn detect_nlweb_entries_none_without_nlweb() {
2455        let summary = serde_json::json!({
2456            "entries": [
2457                {
2458                    "identifier": "urn:example:api",
2459                    "displayName": "Regular API",
2460                    "type": "application/json",
2461                    "url": "https://example.com/api"
2462                }
2463            ]
2464        });
2465        assert!(detect_nlweb_entries(&summary).is_none());
2466    }
2467
2468    #[test]
2469    fn classify_nlweb_ask_valid() {
2470        let body = r#"{"response":"hello","results":[]}"#;
2471        let result = classify_nlweb_ask(body);
2472        assert!(result.is_some());
2473    }
2474
2475    #[test]
2476    fn classify_nlweb_ask_not_nlweb() {
2477        let body = r#"{"data":{"items":[]}}"#;
2478        assert!(classify_nlweb_ask(body).is_none());
2479    }
2480
2481    #[test]
2482    fn classify_nlweb_ask_invalid_json() {
2483        assert!(classify_nlweb_ask("not json").is_none());
2484    }
2485
2486    #[test]
2487    fn classify_nlweb_mcp_init_valid() {
2488        let body = r#"{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"NLWeb Server","version":"0.5"},"protocolVersion":"2025-03-26"}}"#;
2489        let result = classify_nlweb_mcp_init(body);
2490        assert!(result.is_some());
2491        let val = result.unwrap();
2492        assert_eq!(val.get("serverInfo").and_then(|s| s.get("name")).and_then(|n| n.as_str()), Some("NLWeb Server"));
2493    }
2494
2495    #[test]
2496    fn classify_nlweb_mcp_init_not_nlweb() {
2497        let body = r#"{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"Generic MCP","version":"1.0"},"protocolVersion":"2025-03-26"}}"#;
2498        assert!(classify_nlweb_mcp_init(body).is_none());
2499    }
2500
2501    #[test]
2502    fn classify_nlweb_mcp_init_invalid_json() {
2503        assert!(classify_nlweb_mcp_init("not json").is_none());
2504    }
2505
2506    #[test]
2507    fn detect_nlweb_entries_skips_urlless_and_keeps_an_other_endpoint() {
2508        let summary = serde_json::json!({ "entries": [
2509            { "identifier": "urn:nlweb:a", "url": "" },
2510            { "identifier": "urn:nlweb:b", "url": "https://example.com/search" }
2511        ] });
2512        let found = detect_nlweb_entries(&summary).unwrap();
2513        assert_eq!(found["askUrl"], "https://example.com/search");
2514        assert!(found.get("mcpUrl").is_none());
2515    }
2516
2517    #[test]
2518    fn detect_nlweb_entries_prefers_an_explicit_ask_over_an_earlier_generic_entry() {
2519        let summary = serde_json::json!({ "entries": [
2520            { "displayName": "NLWeb service", "url": "https://example.com/service" },
2521            { "identifier": "urn:nlweb:search", "type": "application/nlweb+json", "url": "https://example.com/ask" }
2522        ] });
2523        let found = detect_nlweb_entries(&summary).unwrap();
2524        assert_eq!(found["askUrl"], "https://example.com/ask");
2525    }
2526
2527    #[test]
2528    fn detect_nlweb_entries_does_not_fall_back_when_only_mcp_is_explicit() {
2529        let summary = serde_json::json!({ "entries": [
2530            { "displayName": "NLWeb service", "url": "https://example.com/service" },
2531            { "identifier": "urn:nlweb:mcp", "url": "https://example.com/mcp" }
2532        ] });
2533        let found = detect_nlweb_entries(&summary).unwrap();
2534        assert!(found.get("askUrl").is_none());
2535        assert_eq!(found["mcpUrl"], "https://example.com/mcp");
2536    }
2537}