1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use chio_core::capability::{ModelMetadata, ModelSafetyTier};
5use regex::Regex;
6
7use super::*;
8
9pub(super) fn session_from_map<'a>(
10 sessions: &'a HashMap<SessionId, Session>,
11 session_id: &SessionId,
12) -> Result<&'a Session, KernelError> {
13 sessions
14 .get(session_id)
15 .ok_or_else(|| KernelError::UnknownSession(session_id.clone()))
16}
17
18pub(super) fn session_mut_from_map<'a>(
19 sessions: &'a mut HashMap<SessionId, Session>,
20 session_id: &SessionId,
21) -> Result<&'a mut Session, KernelError> {
22 sessions
23 .get_mut(session_id)
24 .ok_or_else(|| KernelError::UnknownSession(session_id.clone()))
25}
26
27pub(super) fn begin_session_request_in_sessions(
28 sessions: &mut HashMap<SessionId, Session>,
29 context: &OperationContext,
30 operation_kind: OperationKind,
31 cancellable: bool,
32) -> Result<(), KernelError> {
33 let session = session_mut_from_map(sessions, &context.session_id)?;
34 session.validate_context(context)?;
35 session.ensure_operation_allowed(operation_kind)?;
36 session.track_request(context, operation_kind, cancellable)?;
37 Ok(())
38}
39
40pub(super) fn begin_child_request_in_sessions(
41 sessions: &mut HashMap<SessionId, Session>,
42 parent_context: &OperationContext,
43 request_id: RequestId,
44 operation_kind: OperationKind,
45 progress_token: Option<ProgressToken>,
46 cancellable: bool,
47) -> Result<OperationContext, KernelError> {
48 let parent_session = session_from_map(sessions, &parent_context.session_id)?;
49 parent_session.validate_context(parent_context)?;
50 parent_session.validate_parent_request_lineage(&request_id, &parent_context.request_id)?;
51
52 let child_context = OperationContext {
53 session_id: parent_context.session_id.clone(),
54 request_id,
55 agent_id: parent_context.agent_id.clone(),
56 parent_request_id: Some(parent_context.request_id.clone()),
57 progress_token,
58 };
59 begin_session_request_in_sessions(sessions, &child_context, operation_kind, cancellable)?;
60 Ok(child_context)
61}
62
63pub(super) fn complete_session_request_with_terminal_state_in_sessions(
64 sessions: &mut HashMap<SessionId, Session>,
65 session_id: &SessionId,
66 request_id: &RequestId,
67 terminal_state: OperationTerminalState,
68) -> Result<(), KernelError> {
69 session_mut_from_map(sessions, session_id)?
70 .complete_request_with_terminal_state(request_id, terminal_state)?;
71 Ok(())
72}
73
74pub(super) fn validate_sampling_request_in_sessions(
75 sessions: &HashMap<SessionId, Session>,
76 allow_sampling: bool,
77 allow_sampling_tool_use: bool,
78 context: &OperationContext,
79 operation: &CreateMessageOperation,
80) -> Result<(), KernelError> {
81 let session = session_from_map(sessions, &context.session_id)?;
82 session.validate_context(context)?;
83 session.ensure_operation_allowed(OperationKind::CreateMessage)?;
84
85 let parent_request_id = context
86 .parent_request_id
87 .as_ref()
88 .ok_or(KernelError::InvalidChildRequestParent)?;
89 session.validate_parent_request_lineage(&context.request_id, parent_request_id)?;
90
91 if !allow_sampling {
92 return Err(KernelError::SamplingNotAllowedByPolicy);
93 }
94
95 let peer_capabilities = session.peer_capabilities();
96 if !peer_capabilities.supports_sampling {
97 return Err(KernelError::SamplingNotNegotiated);
98 }
99
100 if matches!(
101 operation.include_context.as_deref(),
102 Some("thisServer") | Some("allServers")
103 ) && !peer_capabilities.sampling_context
104 {
105 return Err(KernelError::SamplingContextNotSupported);
106 }
107
108 let requests_tool_use = !operation.tools.is_empty()
109 || operation
110 .tool_choice
111 .as_ref()
112 .is_some_and(|choice| choice.mode != "none");
113 if requests_tool_use {
114 if !allow_sampling_tool_use {
115 return Err(KernelError::SamplingToolUseNotAllowedByPolicy);
116 }
117 if !peer_capabilities.sampling_tools {
118 return Err(KernelError::SamplingToolUseNotNegotiated);
119 }
120 }
121
122 Ok(())
123}
124
125pub(super) fn validate_elicitation_request_in_sessions(
126 sessions: &HashMap<SessionId, Session>,
127 allow_elicitation: bool,
128 context: &OperationContext,
129 operation: &CreateElicitationOperation,
130) -> Result<(), KernelError> {
131 let session = session_from_map(sessions, &context.session_id)?;
132 session.validate_context(context)?;
133 session.ensure_operation_allowed(OperationKind::CreateElicitation)?;
134
135 let parent_request_id = context
136 .parent_request_id
137 .as_ref()
138 .ok_or(KernelError::InvalidChildRequestParent)?;
139 session.validate_parent_request_lineage(&context.request_id, parent_request_id)?;
140
141 if !allow_elicitation {
142 return Err(KernelError::ElicitationNotAllowedByPolicy);
143 }
144
145 let peer_capabilities = session.peer_capabilities();
146 if !peer_capabilities.supports_elicitation {
147 return Err(KernelError::ElicitationNotNegotiated);
148 }
149
150 match operation {
151 CreateElicitationOperation::Form { .. } => {
152 if !peer_capabilities.elicitation_form {
153 return Err(KernelError::ElicitationFormNotSupported);
154 }
155 }
156 CreateElicitationOperation::Url { .. } => {
157 if !peer_capabilities.elicitation_url {
158 return Err(KernelError::ElicitationUrlNotSupported);
159 }
160 }
161 }
162
163 Ok(())
164}
165
166pub(super) fn nested_child_request_id(parent_request_id: &RequestId, suffix: &str) -> RequestId {
167 let nonce = SystemTime::now()
168 .duration_since(UNIX_EPOCH)
169 .unwrap_or_default()
170 .as_nanos();
171 RequestId::new(format!("{parent_request_id}-{suffix}-{nonce}"))
172}
173
174pub(super) fn check_time_bounds(cap: &CapabilityToken, now: u64) -> Result<(), KernelError> {
175 if now >= cap.expires_at {
176 return Err(KernelError::CapabilityExpired);
177 }
178 if now < cap.issued_at {
179 return Err(KernelError::CapabilityNotYetValid);
180 }
181 Ok(())
182}
183
184pub(super) fn check_subject_binding(
185 cap: &CapabilityToken,
186 agent_id: &str,
187) -> Result<(), KernelError> {
188 let expected = cap.subject.to_hex();
189 if expected == agent_id {
190 Ok(())
191 } else {
192 Err(KernelError::SubjectMismatch {
193 expected,
194 actual: agent_id.to_string(),
195 })
196 }
197}
198
199pub fn capability_matches_request(
200 cap: &CapabilityToken,
201 tool_name: &str,
202 server_id: &str,
203 arguments: &serde_json::Value,
204) -> Result<bool, KernelError> {
205 capability_matches_request_with_model_metadata(cap, tool_name, server_id, arguments, None)
206}
207
208pub fn capability_matches_request_with_model_metadata(
209 cap: &CapabilityToken,
210 tool_name: &str,
211 server_id: &str,
212 arguments: &serde_json::Value,
213 model_metadata: Option<&ModelMetadata>,
214) -> Result<bool, KernelError> {
215 Ok(!resolve_matching_grants(cap, tool_name, server_id, arguments, model_metadata)?.is_empty())
216}
217
218pub fn capability_matches_resource_request(
219 cap: &CapabilityToken,
220 uri: &str,
221) -> Result<bool, KernelError> {
222 Ok(cap
223 .scope
224 .resource_grants
225 .iter()
226 .any(|grant| resource_grant_matches_request(grant, uri)))
227}
228
229pub fn capability_matches_resource_subscription(
230 cap: &CapabilityToken,
231 uri: &str,
232) -> Result<bool, KernelError> {
233 Ok(cap
234 .scope
235 .resource_grants
236 .iter()
237 .any(|grant| resource_grant_matches_subscription(grant, uri)))
238}
239
240pub fn capability_matches_resource_pattern(
241 cap: &CapabilityToken,
242 pattern: &str,
243) -> Result<bool, KernelError> {
244 Ok(cap.scope.resource_grants.iter().any(|grant| {
245 resource_pattern_matches(&grant.uri_pattern, pattern)
246 && grant.operations.contains(&Operation::Read)
247 }))
248}
249
250pub fn capability_matches_prompt_request(
251 cap: &CapabilityToken,
252 prompt_name: &str,
253) -> Result<bool, KernelError> {
254 Ok(cap
255 .scope
256 .prompt_grants
257 .iter()
258 .any(|grant| prompt_grant_matches_request(grant, prompt_name)))
259}
260
261pub(super) fn resolve_matching_grants<'a>(
262 cap: &'a CapabilityToken,
263 tool_name: &str,
264 server_id: &str,
265 arguments: &serde_json::Value,
266 model_metadata: Option<&ModelMetadata>,
267) -> Result<Vec<MatchingGrant<'a>>, KernelError> {
268 let mut matches = Vec::new();
269
270 for (index, grant) in cap.scope.grants.iter().enumerate() {
271 if !grant_matches_request(grant, tool_name, server_id, arguments, model_metadata)? {
272 continue;
273 }
274
275 matches.push(MatchingGrant {
276 index,
277 grant,
278 specificity: (
279 u8::from(grant.server_id == server_id),
280 u8::from(grant.tool_name == tool_name),
281 grant.constraints.len(),
282 ),
283 });
284 }
285
286 matches.sort_by(|left, right| {
287 right
288 .specificity
289 .cmp(&left.specificity)
290 .then_with(|| left.index.cmp(&right.index))
291 });
292
293 Ok(matches)
294}
295
296fn grant_matches_request(
297 grant: &ToolGrant,
298 tool_name: &str,
299 server_id: &str,
300 arguments: &serde_json::Value,
301 model_metadata: Option<&ModelMetadata>,
302) -> Result<bool, KernelError> {
303 Ok(matches_server(&grant.server_id, server_id)
304 && matches_name(&grant.tool_name, tool_name)
305 && grant.operations.contains(&Operation::Invoke)
306 && constraints_match(&grant.constraints, arguments, model_metadata)?)
307}
308
309fn matches_server(pattern: &str, server_id: &str) -> bool {
310 pattern == "*" || pattern == server_id
311}
312
313fn matches_name(pattern: &str, name: &str) -> bool {
314 pattern == "*" || pattern == name
315}
316
317fn constraints_match(
318 constraints: &[Constraint],
319 arguments: &serde_json::Value,
320 model_metadata: Option<&ModelMetadata>,
321) -> Result<bool, KernelError> {
322 for constraint in constraints {
323 if !constraint_matches(constraint, arguments, model_metadata)? {
324 return Ok(false);
325 }
326 }
327 Ok(true)
328}
329
330fn constraint_matches(
331 constraint: &Constraint,
332 arguments: &serde_json::Value,
333 model_metadata: Option<&ModelMetadata>,
334) -> Result<bool, KernelError> {
335 let string_leaves = collect_string_leaves(arguments);
336
337 match constraint {
338 Constraint::PathPrefix(prefix) => {
339 let candidates: Vec<&str> = string_leaves
340 .iter()
341 .filter(|leaf| {
342 leaf.key.as_deref().is_some_and(is_path_key) || looks_like_path(&leaf.value)
343 })
344 .map(|leaf| leaf.value.as_str())
345 .collect();
346 Ok(!candidates.is_empty()
347 && candidates
348 .into_iter()
349 .all(|path| path_has_prefix(path, prefix)))
350 }
351 Constraint::DomainExact(expected) => {
352 let expected = normalize_domain(expected);
353 let domains = collect_domain_candidates(&string_leaves);
354 Ok(!domains.is_empty() && domains.into_iter().all(|domain| domain == expected))
355 }
356 Constraint::DomainGlob(pattern) => {
357 let pattern = pattern.to_ascii_lowercase();
358 let domains = collect_domain_candidates(&string_leaves);
359 Ok(!domains.is_empty()
360 && domains
361 .into_iter()
362 .all(|domain| wildcard_matches(&pattern, &domain)))
363 }
364 Constraint::RegexMatch(pattern) => {
365 let regex = Regex::new(pattern).map_err(|error| {
366 KernelError::InvalidConstraint(format!(
367 "regex \"{pattern}\" failed to compile: {error}"
368 ))
369 })?;
370 Ok(string_leaves.iter().any(|leaf| regex.is_match(&leaf.value)))
371 }
372 Constraint::MaxLength(max) => Ok(string_leaves.iter().all(|leaf| leaf.value.len() <= *max)),
373 Constraint::MaxArgsSize(max) => Ok(arguments.to_string().len() <= *max),
374 Constraint::GovernedIntentRequired
375 | Constraint::RequireApprovalAbove { .. }
376 | Constraint::SellerExact(_)
377 | Constraint::MinimumRuntimeAssurance(_)
378 | Constraint::MinimumAutonomyTier(_) => Ok(true),
379 Constraint::Custom(key, expected) => Ok(argument_contains_custom(arguments, key, expected)),
380
381 Constraint::TableAllowlist(_)
389 | Constraint::ColumnDenylist(_)
390 | Constraint::MaxRowsReturned(_)
391 | Constraint::OperationClass(_) => Ok(true),
392 Constraint::ContentReviewTier(_) => Ok(false),
393 Constraint::MaxTransactionAmountUsd(_) | Constraint::RequireDualApproval(_) => Ok(false),
394
395 Constraint::ModelConstraint {
401 allowed_model_ids,
402 min_safety_tier,
403 } => Ok(model_constraint_matches(
404 allowed_model_ids,
405 *min_safety_tier,
406 model_metadata,
407 )),
408
409 Constraint::AudienceAllowlist(allowed) => {
410 Ok(audience_allowlist_matches(arguments, allowed))
411 }
412 Constraint::MemoryStoreAllowlist(allowed) => {
413 Ok(memory_store_allowlist_matches(arguments, allowed))
414 }
415 Constraint::MemoryWriteDenyPatterns(patterns) => {
416 memory_write_deny_patterns_match(arguments, patterns)
417 }
418 }
419}
420
421#[cfg(test)]
422#[allow(clippy::expect_used, clippy::unwrap_used)]
423mod tests {
424 use super::*;
425 use chio_core::capability::{
426 CapabilityTokenBody, ChioScope, Constraint, ContentReviewTier, Operation, ToolGrant,
427 };
428 use chio_core::crypto::Keypair;
429
430 fn capability_with_constraints(constraints: Vec<Constraint>) -> CapabilityToken {
431 let issuer = Keypair::generate();
432 CapabilityToken::sign(
433 CapabilityTokenBody {
434 id: "cap-request-matching".to_string(),
435 issuer: issuer.public_key(),
436 subject: issuer.public_key(),
437 scope: ChioScope {
438 grants: vec![ToolGrant {
439 server_id: "srv".to_string(),
440 tool_name: "tool".to_string(),
441 operations: vec![Operation::Invoke],
442 constraints,
443 max_invocations: None,
444 max_cost_per_invocation: None,
445 max_total_cost: None,
446 dpop_required: None,
447 }],
448 ..ChioScope::default()
449 },
450 issued_at: 1,
451 expires_at: u64::MAX,
452 delegation_chain: Vec::new(),
453 },
454 &issuer,
455 )
456 .expect("sign capability")
457 }
458
459 #[test]
460 fn content_review_tier_fails_closed_without_review_guard_context() {
461 let capability = capability_with_constraints(vec![Constraint::ContentReviewTier(
462 ContentReviewTier::Strict,
463 )]);
464 assert!(
465 !capability_matches_request(
466 &capability,
467 "tool",
468 "srv",
469 &serde_json::json!({"text": "review this outbound message"}),
470 )
471 .expect("evaluate request match"),
472 "content review tier should deny until a review guard supplies runtime context"
473 );
474 }
475
476 #[test]
477 fn governed_transaction_constraints_fail_closed_without_specialized_enforcement() {
478 let constraints = [
479 Constraint::MaxTransactionAmountUsd("100.00".to_string()),
480 Constraint::RequireDualApproval(true),
481 ];
482
483 for constraint in constraints {
484 let capability = capability_with_constraints(vec![constraint]);
485 assert!(
486 !capability_matches_request(
487 &capability,
488 "tool",
489 "srv",
490 &serde_json::json!({"amount_usd": "25.00"}),
491 )
492 .expect("evaluate request match"),
493 "governed transaction constraint should deny without its dedicated enforcement path"
494 );
495 }
496 }
497
498 #[test]
499 fn path_prefix_constraint_rejects_traversal_and_sibling_prefixes() {
500 let capability = capability_with_constraints(vec![Constraint::PathPrefix(
501 "/workspace/safe".to_string(),
502 )]);
503
504 assert!(capability_matches_request(
505 &capability,
506 "tool",
507 "srv",
508 &serde_json::json!({"path": "/workspace/safe/report.txt"}),
509 )
510 .expect("allow matching path"),);
511 assert!(!capability_matches_request(
512 &capability,
513 "tool",
514 "srv",
515 &serde_json::json!({"path": "/workspace/safe/../secret.txt"}),
516 )
517 .expect("deny traversal path"),);
518 assert!(!capability_matches_request(
519 &capability,
520 "tool",
521 "srv",
522 &serde_json::json!({"path": "/workspace/safeX/report.txt"}),
523 )
524 .expect("deny sibling prefix"),);
525 }
526
527 #[test]
528 fn audience_allowlist_rejects_non_string_values() {
529 assert!(audience_allowlist_matches(
530 &serde_json::json!({"recipient": "#ops"}),
531 &["#ops".to_string()]
532 ));
533 assert!(!audience_allowlist_matches(
534 &serde_json::json!({"recipient": {"channel": "#ops"}}),
535 &["#ops".to_string()]
536 ));
537 assert!(!audience_allowlist_matches(
538 &serde_json::json!({"recipients": []}),
539 &["#ops".to_string()]
540 ));
541 }
542
543 #[test]
544 fn memory_store_allowlist_rejects_non_string_values() {
545 assert!(memory_store_allowlist_matches(
546 &serde_json::json!({"store": "session-cache"}),
547 &["session-cache".to_string()]
548 ));
549 assert!(!memory_store_allowlist_matches(
550 &serde_json::json!({"store": {"name": "session-cache"}}),
551 &["session-cache".to_string()]
552 ));
553 assert!(!memory_store_allowlist_matches(
554 &serde_json::json!({"store": null}),
555 &["session-cache".to_string()]
556 ));
557 }
558}
559
560fn model_constraint_matches(
576 allowed_model_ids: &[String],
577 min_safety_tier: Option<ModelSafetyTier>,
578 model_metadata: Option<&ModelMetadata>,
579) -> bool {
580 let has_allowlist = !allowed_model_ids.is_empty();
581 let has_tier_floor = min_safety_tier.is_some();
582 if !has_allowlist && !has_tier_floor {
583 return true;
584 }
585
586 let Some(metadata) = model_metadata else {
587 return false;
588 };
589
590 if has_allowlist
591 && !allowed_model_ids
592 .iter()
593 .any(|allowed| allowed == &metadata.model_id)
594 {
595 return false;
596 }
597
598 if let Some(required_tier) = min_safety_tier {
599 match metadata.safety_tier {
600 Some(actual) if actual >= required_tier => {}
601 _ => return false,
602 }
603 }
604
605 true
606}
607
608fn audience_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
614 let mut observed = ObservedStringValues::default();
615 collect_audience_values(arguments, &mut observed);
616 if observed.invalid {
617 return false;
618 }
619 if !observed.saw_relevant_key {
620 return true;
621 }
622 observed
623 .values
624 .iter()
625 .all(|value| allowed.iter().any(|a| a == value))
626}
627
628fn collect_audience_values(arguments: &serde_json::Value, out: &mut ObservedStringValues) {
629 match arguments {
630 serde_json::Value::Object(map) => {
631 for (key, value) in map {
632 if is_audience_key(key) {
633 let before = out.values.len();
634 out.saw_relevant_key = true;
635 if !collect_string_values_strict(value, &mut out.values)
636 || out.values.len() == before
637 {
638 out.invalid = true;
639 }
640 } else {
641 collect_audience_values(value, out);
642 }
643 }
644 }
645 serde_json::Value::Array(values) => {
646 for value in values {
647 collect_audience_values(value, out);
648 }
649 }
650 _ => {}
651 }
652}
653
654fn is_audience_key(key: &str) -> bool {
655 matches!(
656 key.to_ascii_lowercase().as_str(),
657 "recipient" | "recipients" | "audience" | "to" | "channel" | "channels"
658 )
659}
660
661fn collect_string_values_strict(value: &serde_json::Value, out: &mut Vec<String>) -> bool {
662 match value {
663 serde_json::Value::String(s) => {
664 out.push(s.clone());
665 true
666 }
667 serde_json::Value::Array(values) => {
668 for v in values {
669 if !collect_string_values_strict(v, out) {
670 return false;
671 }
672 }
673 true
674 }
675 _ => false,
676 }
677}
678
679fn memory_store_allowlist_matches(arguments: &serde_json::Value, allowed: &[String]) -> bool {
682 let mut observed = ObservedStringValues::default();
683 collect_memory_store_values(arguments, &mut observed);
684 if observed.invalid {
685 return false;
686 }
687 if !observed.saw_relevant_key {
688 return true;
689 }
690 observed
691 .values
692 .iter()
693 .all(|value| allowed.iter().any(|a| a == value))
694}
695
696fn collect_memory_store_values(arguments: &serde_json::Value, out: &mut ObservedStringValues) {
697 match arguments {
698 serde_json::Value::Object(map) => {
699 for (key, value) in map {
700 if is_memory_store_key(key) {
701 let before = out.values.len();
702 out.saw_relevant_key = true;
703 if !collect_string_values_strict(value, &mut out.values)
704 || out.values.len() == before
705 {
706 out.invalid = true;
707 }
708 } else {
709 collect_memory_store_values(value, out);
710 }
711 }
712 }
713 serde_json::Value::Array(values) => {
714 for value in values {
715 collect_memory_store_values(value, out);
716 }
717 }
718 _ => {}
719 }
720}
721
722fn is_memory_store_key(key: &str) -> bool {
723 matches!(
724 key.to_ascii_lowercase().as_str(),
725 "store" | "memory_store" | "collection" | "namespace"
726 )
727}
728
729fn memory_write_deny_patterns_match(
732 arguments: &serde_json::Value,
733 patterns: &[String],
734) -> Result<bool, KernelError> {
735 let leaves = collect_string_leaves(arguments);
736 for pattern in patterns {
737 let regex = Regex::new(pattern).map_err(|error| {
738 KernelError::InvalidConstraint(format!(
739 "memory write deny pattern \"{pattern}\" failed to compile: {error}"
740 ))
741 })?;
742 for leaf in &leaves {
743 if regex.is_match(&leaf.value) {
744 return Ok(false);
745 }
746 }
747 }
748 Ok(true)
749}
750
751fn resource_grant_matches_request(grant: &ResourceGrant, uri: &str) -> bool {
752 resource_pattern_matches(&grant.uri_pattern, uri) && grant.operations.contains(&Operation::Read)
753}
754
755fn resource_grant_matches_subscription(grant: &ResourceGrant, uri: &str) -> bool {
756 resource_pattern_matches(&grant.uri_pattern, uri)
757 && grant.operations.contains(&Operation::Subscribe)
758}
759
760fn prompt_grant_matches_request(grant: &PromptGrant, prompt_name: &str) -> bool {
761 matches_pattern(&grant.prompt_name, prompt_name) && grant.operations.contains(&Operation::Get)
762}
763
764fn resource_pattern_matches(pattern: &str, uri: &str) -> bool {
765 matches_pattern(pattern, uri)
766}
767
768fn matches_pattern(pattern: &str, value: &str) -> bool {
769 if pattern == "*" {
770 return true;
771 }
772
773 if let Some(prefix) = pattern.strip_suffix('*') {
774 return value.starts_with(prefix);
775 }
776
777 pattern == value
778}
779
780fn path_has_prefix(candidate: &str, prefix: &str) -> bool {
781 let Some(candidate) = normalize_path(candidate) else {
782 return false;
783 };
784 let Some(prefix) = normalize_path(prefix) else {
785 return false;
786 };
787 if candidate.is_absolute != prefix.is_absolute {
788 return false;
789 }
790 if prefix.segments.len() > candidate.segments.len() {
791 return false;
792 }
793 prefix
794 .segments
795 .iter()
796 .zip(candidate.segments.iter())
797 .all(|(expected, actual)| expected == actual)
798}
799
800#[derive(Debug, PartialEq, Eq)]
801struct NormalizedPath {
802 is_absolute: bool,
803 segments: Vec<String>,
804}
805
806fn normalize_path(path: &str) -> Option<NormalizedPath> {
807 let is_absolute = path.starts_with('/') || path.starts_with('\\');
808 let mut segments = Vec::new();
809 for segment in path.split(['/', '\\']) {
810 if segment.is_empty() || segment == "." {
811 continue;
812 }
813 if segment == ".." {
814 segments.pop()?;
815 continue;
816 }
817 segments.push(segment.to_string());
818 }
819 Some(NormalizedPath {
820 is_absolute,
821 segments,
822 })
823}
824
825#[derive(Clone)]
826struct StringLeaf {
827 key: Option<String>,
828 value: String,
829}
830
831#[derive(Default)]
832struct ObservedStringValues {
833 values: Vec<String>,
834 saw_relevant_key: bool,
835 invalid: bool,
836}
837
838fn collect_string_leaves(arguments: &serde_json::Value) -> Vec<StringLeaf> {
839 let mut leaves = Vec::new();
840 collect_string_leaves_inner(arguments, None, &mut leaves);
841 leaves
842}
843
844fn collect_string_leaves_inner(
845 arguments: &serde_json::Value,
846 current_key: Option<&str>,
847 leaves: &mut Vec<StringLeaf>,
848) {
849 match arguments {
850 serde_json::Value::String(value) => leaves.push(StringLeaf {
851 key: current_key.map(str::to_string),
852 value: value.clone(),
853 }),
854 serde_json::Value::Array(values) => {
855 for value in values {
856 collect_string_leaves_inner(value, current_key, leaves);
857 }
858 }
859 serde_json::Value::Object(map) => {
860 for (key, value) in map {
861 collect_string_leaves_inner(value, Some(key), leaves);
862 }
863 }
864 serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
865 }
866}
867
868fn is_path_key(key: &str) -> bool {
869 let key = key.to_ascii_lowercase();
870 key.contains("path")
871 || matches!(
872 key.as_str(),
873 "file" | "filepath" | "dir" | "directory" | "root" | "cwd"
874 )
875}
876
877fn looks_like_path(value: &str) -> bool {
878 !value.contains("://")
879 && (value.starts_with('/')
880 || value.starts_with("./")
881 || value.starts_with("../")
882 || value.starts_with("~/")
883 || value.contains('/')
884 || value.contains('\\'))
885}
886
887fn collect_domain_candidates(string_leaves: &[StringLeaf]) -> Vec<String> {
888 string_leaves
889 .iter()
890 .filter_map(|leaf| parse_domain(&leaf.value))
891 .collect()
892}
893
894fn parse_domain(value: &str) -> Option<String> {
895 let trimmed = value.trim();
896 if trimmed.is_empty() {
897 return None;
898 }
899
900 let host_port = if let Some((_, rest)) = trimmed.split_once("://") {
901 rest
902 } else {
903 trimmed
904 };
905
906 let authority = host_port
907 .split(['/', '?', '#'])
908 .next()
909 .unwrap_or(host_port)
910 .rsplit('@')
911 .next()
912 .unwrap_or(host_port);
913 let host = authority
914 .split(':')
915 .next()
916 .unwrap_or(authority)
917 .trim_matches('.');
918 let normalized = normalize_domain(host);
919
920 if normalized == "localhost"
921 || (!normalized.is_empty()
922 && normalized.contains('.')
923 && normalized.chars().all(|character| {
924 character.is_ascii_alphanumeric() || character == '-' || character == '.'
925 }))
926 {
927 Some(normalized)
928 } else {
929 None
930 }
931}
932
933fn normalize_domain(value: &str) -> String {
934 value.trim().trim_matches('.').to_ascii_lowercase()
935}
936
937fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
938 let pattern_chars: Vec<char> = pattern.chars().collect();
939 let candidate_chars: Vec<char> = candidate.chars().collect();
940 let (mut pattern_idx, mut candidate_idx) = (0usize, 0usize);
941 let (mut star_idx, mut match_idx) = (None, 0usize);
942
943 while candidate_idx < candidate_chars.len() {
944 if pattern_idx < pattern_chars.len()
945 && (pattern_chars[pattern_idx] == candidate_chars[candidate_idx]
946 || pattern_chars[pattern_idx] == '*')
947 {
948 if pattern_chars[pattern_idx] == '*' {
949 star_idx = Some(pattern_idx);
950 match_idx = candidate_idx;
951 pattern_idx += 1;
952 } else {
953 pattern_idx += 1;
954 candidate_idx += 1;
955 }
956 } else if let Some(star_position) = star_idx {
957 pattern_idx = star_position + 1;
958 match_idx += 1;
959 candidate_idx = match_idx;
960 } else {
961 return false;
962 }
963 }
964
965 while pattern_idx < pattern_chars.len() && pattern_chars[pattern_idx] == '*' {
966 pattern_idx += 1;
967 }
968
969 pattern_idx == pattern_chars.len()
970}
971
972fn argument_contains_custom(arguments: &serde_json::Value, key: &str, expected: &str) -> bool {
973 match arguments {
974 serde_json::Value::Object(map) => map.iter().any(|(entry_key, value)| {
975 (entry_key == key && value.as_str() == Some(expected))
976 || argument_contains_custom(value, key, expected)
977 }),
978 serde_json::Value::Array(values) => values
979 .iter()
980 .any(|value| argument_contains_custom(value, key, expected)),
981 serde_json::Value::Null
982 | serde_json::Value::Bool(_)
983 | serde_json::Value::Number(_)
984 | serde_json::Value::String(_) => false,
985 }
986}