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}