vellaveto_engine/compiled.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Pre-compiled policy types.
9//!
10//! This module contains the pre-compiled policy types that are created at policy
11//! load time. Pre-compilation allows policy evaluation to be performed without
12//! any runtime pattern compilation or lock contention.
13
14use crate::matcher::{CompiledToolMatcher, PatternMatcher};
15use globset::GlobMatcher;
16use ipnet::IpNet;
17use regex::Regex;
18use std::collections::{HashMap, HashSet};
19use vellaveto_types::Policy;
20
21/// A single pre-compiled parameter constraint with all patterns resolved at load time.
22#[derive(Debug, Clone)]
23pub enum CompiledConstraint {
24 Glob {
25 param: String,
26 matcher: GlobMatcher,
27 pattern_str: String,
28 on_match: String,
29 on_missing: String,
30 },
31 NotGlob {
32 param: String,
33 matchers: Vec<(String, GlobMatcher)>,
34 on_match: String,
35 on_missing: String,
36 },
37 Regex {
38 param: String,
39 regex: Regex,
40 pattern_str: String,
41 on_match: String,
42 on_missing: String,
43 },
44 DomainMatch {
45 param: String,
46 pattern: String,
47 on_match: String,
48 on_missing: String,
49 },
50 DomainNotIn {
51 param: String,
52 patterns: Vec<String>,
53 on_match: String,
54 on_missing: String,
55 },
56 Eq {
57 param: String,
58 value: serde_json::Value,
59 on_match: String,
60 on_missing: String,
61 },
62 Ne {
63 param: String,
64 value: serde_json::Value,
65 on_match: String,
66 on_missing: String,
67 },
68 OneOf {
69 param: String,
70 values: Vec<serde_json::Value>,
71 on_match: String,
72 on_missing: String,
73 },
74 NoneOf {
75 param: String,
76 values: Vec<serde_json::Value>,
77 on_match: String,
78 on_missing: String,
79 },
80}
81
82impl CompiledConstraint {
83 pub(crate) fn param(&self) -> &str {
84 match self {
85 Self::Glob { param, .. }
86 | Self::NotGlob { param, .. }
87 | Self::Regex { param, .. }
88 | Self::DomainMatch { param, .. }
89 | Self::DomainNotIn { param, .. }
90 | Self::Eq { param, .. }
91 | Self::Ne { param, .. }
92 | Self::OneOf { param, .. }
93 | Self::NoneOf { param, .. } => param,
94 }
95 }
96
97 pub(crate) fn on_match(&self) -> &str {
98 match self {
99 Self::Glob { on_match, .. }
100 | Self::NotGlob { on_match, .. }
101 | Self::Regex { on_match, .. }
102 | Self::DomainMatch { on_match, .. }
103 | Self::DomainNotIn { on_match, .. }
104 | Self::Eq { on_match, .. }
105 | Self::Ne { on_match, .. }
106 | Self::OneOf { on_match, .. }
107 | Self::NoneOf { on_match, .. } => on_match,
108 }
109 }
110
111 pub(crate) fn on_missing(&self) -> &str {
112 match self {
113 Self::Glob { on_missing, .. }
114 | Self::NotGlob { on_missing, .. }
115 | Self::Regex { on_missing, .. }
116 | Self::DomainMatch { on_missing, .. }
117 | Self::DomainNotIn { on_missing, .. }
118 | Self::Eq { on_missing, .. }
119 | Self::Ne { on_missing, .. }
120 | Self::OneOf { on_missing, .. }
121 | Self::NoneOf { on_missing, .. } => on_missing,
122 }
123 }
124}
125
126/// Pre-compiled path rule glob matchers for a single policy.
127#[derive(Debug, Clone)]
128pub struct CompiledPathRules {
129 pub allowed: Vec<(String, GlobMatcher)>,
130 pub blocked: Vec<(String, GlobMatcher)>,
131}
132
133/// Pre-compiled network rule domain patterns for a single policy.
134#[derive(Debug, Clone)]
135pub struct CompiledNetworkRules {
136 pub allowed_domains: Vec<String>,
137 pub blocked_domains: Vec<String>,
138}
139
140/// Pre-compiled IP access control rules for DNS rebinding protection.
141///
142/// CIDRs are parsed at policy compile time so evaluation is a fast
143/// prefix-length comparison with no parsing overhead.
144#[derive(Debug, Clone)]
145pub struct CompiledIpRules {
146 pub block_private: bool,
147 pub blocked_cidrs: Vec<IpNet>,
148 pub allowed_cidrs: Vec<IpNet>,
149}
150
151/// A pre-compiled context condition for session-level policy evaluation.
152///
153/// Context conditions are checked after tool match and path/network rules,
154/// but before policy type dispatch. They require an [`vellaveto_types::EvaluationContext`]
155/// to evaluate — when no context is provided, all context conditions are skipped.
156#[derive(Debug, Clone)]
157pub enum CompiledContextCondition {
158 /// Allow tool calls only within a time window.
159 TimeWindow {
160 start_hour: u8,
161 end_hour: u8,
162 /// ISO weekday numbers (1=Mon, 7=Sun). Empty = all days.
163 days: Vec<u8>,
164 deny_reason: String,
165 },
166 /// Limit how many times a tool (or tool pattern) can be called per session.
167 MaxCalls {
168 tool_pattern: PatternMatcher,
169 max: u64,
170 deny_reason: String,
171 },
172 /// Restrict which agent identities can use this policy.
173 AgentId {
174 allowed: Vec<String>,
175 blocked: Vec<String>,
176 deny_reason: String,
177 },
178 /// Require that a specific tool was called earlier in the session.
179 RequirePreviousAction {
180 required_tool: String,
181 deny_reason: String,
182 },
183 /// Deny if a specific tool was called earlier in the session.
184 ///
185 /// Inverse of `RequirePreviousAction` — detects forbidden sequences like
186 /// read-then-exfiltrate (if `read_file` was called, deny `http_request`).
187 ForbiddenPreviousAction {
188 /// Tool name that, if present in session history, triggers denial.
189 forbidden_tool: String,
190 deny_reason: String,
191 },
192 /// Deny if a tool pattern appears more than `max` times in the last `window`
193 /// entries of the session history.
194 ///
195 /// Provides sliding-window rate limiting without requiring wall-clock
196 /// timestamps. A `window` of 0 means the entire session history.
197 MaxCallsInWindow {
198 tool_pattern: PatternMatcher,
199 max: u64,
200 /// Number of most-recent history entries to consider. 0 = all.
201 window: usize,
202 deny_reason: String,
203 },
204 /// OWASP ASI08: Limit the depth of multi-agent call chains.
205 ///
206 /// In multi-hop MCP scenarios, an agent can request another agent to perform
207 /// actions on its behalf. This condition limits how deep such chains can go
208 /// to prevent privilege escalation through agent chaining.
209 MaxChainDepth {
210 /// Maximum allowed chain depth. This is an exclusive upper bound: a call
211 /// chain with `len > max_depth` entries is denied. A value of 0 means only
212 /// direct calls are allowed (empty chain); any upstream hop is denied.
213 /// A value of 1 allows exactly one upstream agent, etc.
214 max_depth: usize,
215 deny_reason: String,
216 },
217 /// OWASP ASI07: Match on cryptographically attested agent identity claims.
218 ///
219 /// Requires a valid `X-Agent-Identity` JWT header. Policies can match on:
220 /// - `issuer`: Required JWT issuer (`iss` claim)
221 /// - `subject`: Required JWT subject (`sub` claim)
222 /// - `audience`: Required audience (`aud` claim must contain this value)
223 /// - `claims.<key>`: Custom claim matching (e.g., `claims.role == "admin"`)
224 ///
225 /// Unlike `AgentId` which matches on a simple string, this condition provides
226 /// cryptographic attestation of the agent's identity via JWT signature verification.
227 AgentIdentityMatch {
228 /// Required JWT issuer. If set, the identity's `iss` claim must match.
229 required_issuer: Option<String>,
230 /// Required JWT subject. If set, the identity's `sub` claim must match.
231 required_subject: Option<String>,
232 /// Required audience. If set, the identity's `aud` claim must contain this value.
233 required_audience: Option<String>,
234 /// Required custom claims. All specified claims must match.
235 /// Keys are claim names, values are expected string values.
236 required_claims: HashMap<String, String>,
237 /// Blocked issuers. If the identity's `iss` matches any, deny.
238 blocked_issuers: Vec<String>,
239 /// Blocked subjects. If the identity's `sub` matches any, deny.
240 blocked_subjects: Vec<String>,
241 /// When true, fail-closed if no agent_identity is present.
242 /// When false, fall back to legacy agent_id matching.
243 require_attestation: bool,
244 deny_reason: String,
245 },
246
247 // ═══════════════════════════════════════════════════
248 // MCP 2025-11-25 CONTEXT CONDITIONS
249 // ═══════════════════════════════════════════════════
250 /// MCP 2025-11-25: Async task lifecycle policy.
251 ///
252 /// Controls the creation and cancellation of async MCP tasks. Policies can:
253 /// - Limit maximum concurrent tasks per session/agent
254 /// - Set maximum task duration before automatic expiry
255 /// - Restrict task cancellation to the creating agent only
256 AsyncTaskPolicy {
257 /// Maximum number of concurrent active tasks. 0 = unlimited.
258 max_concurrent: usize,
259 /// Maximum task duration in seconds. 0 = unlimited.
260 max_duration_secs: u64,
261 /// When true, only the agent that created a task can cancel it.
262 require_self_cancel: bool,
263 deny_reason: String,
264 },
265
266 /// RFC 8707: OAuth 2.0 Resource Indicator validation.
267 ///
268 /// Validates that OAuth tokens include the expected resource indicators.
269 /// Resource indicators prevent token replay attacks by binding tokens
270 /// to specific API endpoints or resource servers.
271 ResourceIndicator {
272 /// Patterns for allowed resource URIs. Supports glob patterns.
273 /// If non-empty, at least one pattern must match the token's resource.
274 allowed_resources: Vec<PatternMatcher>,
275 /// When true, deny if the token has no resource indicator.
276 require_resource: bool,
277 deny_reason: String,
278 },
279
280 /// CIMD: Capability-Indexed Message Dispatch.
281 ///
282 /// MCP 2025-11-25 introduces capability negotiation. This condition
283 /// checks that the client has declared the required capabilities
284 /// and has not declared any blocked capabilities.
285 CapabilityRequired {
286 /// Capabilities that must be declared by the client.
287 /// All listed capabilities must be present.
288 required_capabilities: Vec<String>,
289 /// Capabilities that must NOT be declared by the client.
290 /// If any listed capability is present, deny.
291 blocked_capabilities: Vec<String>,
292 deny_reason: String,
293 },
294
295 /// Step-up authentication trigger.
296 ///
297 /// When the current authentication level is below the required level,
298 /// the policy triggers a step-up authentication challenge instead of
299 /// denying outright. This allows sensitive operations to require
300 /// stronger authentication without blocking the session.
301 StepUpAuth {
302 /// Required authentication level (maps to AuthLevel enum).
303 /// 0=None, 1=Basic, 2=OAuth, 3=OAuthMfa, 4=HardwareKey
304 required_level: u8,
305 deny_reason: String,
306 },
307
308 // ═══════════════════════════════════════════════════
309 // PHASE 2: ADVANCED THREAT DETECTION CONDITIONS
310 // ═══════════════════════════════════════════════════
311 /// Circuit breaker check (OWASP ASI08).
312 ///
313 /// Prevents cascading failures by temporarily blocking requests to
314 /// tools that have been failing. The circuit breaker pattern has
315 /// three states: Closed (normal), Open (blocking), HalfOpen (testing).
316 CircuitBreaker {
317 /// Pattern to match tool names for circuit breaker tracking.
318 tool_pattern: PatternMatcher,
319 deny_reason: String,
320 },
321
322 /// Confused deputy validation (OWASP ASI02).
323 ///
324 /// Validates that the current principal is authorized to perform
325 /// the requested action, preventing confused deputy attacks where
326 /// a privileged agent is tricked into acting on behalf of an
327 /// unprivileged attacker.
328 DeputyValidation {
329 /// When true, a principal must be identified in the context.
330 require_principal: bool,
331 /// Maximum allowed delegation depth. 0 = direct only.
332 max_delegation_depth: u8,
333 deny_reason: String,
334 },
335
336 /// Shadow agent detection.
337 ///
338 /// Detects when an unknown agent claims to be a known agent,
339 /// indicating potential impersonation or shadow agent attack.
340 /// Fingerprints agents based on JWT claims, client ID, and IP.
341 ShadowAgentCheck {
342 /// When true, require the fingerprint to match a known agent.
343 require_known_fingerprint: bool,
344 /// Minimum trust level required (0-4).
345 /// 0=Unknown, 1=Low, 2=Medium, 3=High, 4=Verified
346 min_trust_level: u8,
347 deny_reason: String,
348 },
349
350 /// Schema poisoning protection (OWASP ASI05).
351 ///
352 /// Tracks tool schema changes over time and alerts or blocks
353 /// when schemas change beyond the configured threshold.
354 /// Prevents rug-pull attacks where tool behavior changes maliciously.
355 SchemaPoisoningCheck {
356 /// Schema similarity threshold (0.0-1.0). Changes above this trigger denial.
357 mutation_threshold: f32,
358 deny_reason: String,
359 },
360
361 /// Minimum verification tier enforcement.
362 ///
363 /// Requires the agent's verification tier to meet or exceed a minimum level.
364 /// Fail-closed: if no verification tier is present in the context, denies.
365 MinVerificationTier {
366 /// Required tier level (0-4).
367 /// 0=Unverified, 1=EmailVerified, 2=PhoneVerified, 3=DidVerified, 4=FullyVerified
368 required_tier: u8,
369 deny_reason: String,
370 },
371
372 /// Capability-based delegation token enforcement.
373 ///
374 /// Requires a valid capability token to be present in the evaluation context.
375 /// Checks that the token's holder matches the agent_id, and optionally
376 /// restricts which issuers are trusted and requires minimum delegation depth.
377 ///
378 /// # Security (MCP Gap #3 — Capability Delegation)
379 ///
380 /// - Fail-closed: missing token = Deny
381 /// - Holder must match agent_id (prevents token theft)
382 /// - Issuer allowlist prevents unauthorized token sources
383 /// - Grant coverage is verified by the proxy layer before attaching to context
384 RequireCapabilityToken {
385 /// If non-empty, the token's issuer must be in this list.
386 required_issuers: Vec<String>,
387 /// Minimum remaining delegation depth required (0 = terminal tokens accepted).
388 min_remaining_depth: u8,
389 deny_reason: String,
390 },
391
392 /// Session state requirement (Phase 23.5).
393 ///
394 /// Only allows actions when the session is in one of the specified states.
395 /// Fail-closed: if no session_state is present in context, denies.
396 SessionStateRequired {
397 /// Allowed session states (e.g., ["active", "init"]).
398 /// State names are compared case-insensitively.
399 allowed_states: Vec<String>,
400 deny_reason: String,
401 },
402
403 // ═══════════════════════════════════════════════════
404 // PHASE 40: WORKFLOW-LEVEL POLICY CONSTRAINTS
405 // ═══════════════════════════════════════════════════
406 /// Require an ordered (or unordered) sequence of tools in session history.
407 ///
408 /// **Ordered:** subsequence match — tools must appear in order, not necessarily
409 /// consecutive (e.g., A→C→B history matches A→B sequence).
410 /// **Unordered:** all tools must appear anywhere in history (set semantics).
411 ///
412 /// Max 20 steps. Fail-closed: empty/shorter history → Deny.
413 RequiredActionSequence {
414 /// Tool names (lowercased at compile time).
415 sequence: Vec<String>,
416 /// `true` = ordered subsequence, `false` = unordered set.
417 ordered: bool,
418 deny_reason: String,
419 },
420
421 /// Deny if a sequence of tools has appeared in session history.
422 ///
423 /// **Ordered:** subsequence match — if all tools found in order → Deny.
424 /// **Unordered:** if all tools present anywhere → Deny.
425 ///
426 /// Max 20 steps. Empty history → Allow (nothing forbidden yet).
427 ///
428 /// # Known limitation (FIND-CREATIVE-004)
429 ///
430 /// `previous_actions` is bounded at `MAX_PREVIOUS_ACTIONS` (10,000 entries).
431 /// If an attacker performs the forbidden prefix actions and then issues enough
432 /// additional tool calls to push the prefix out of the retained history window,
433 /// the forbidden sequence will no longer be detected. This is an inherent
434 /// trade-off of bounded history. When the history is at capacity, a warning
435 /// is emitted so operators can investigate or increase monitoring. Consider
436 /// pairing `ForbiddenActionSequence` with `ForbiddenPreviousAction` for
437 /// individual high-risk tools that must never appear at all.
438 ForbiddenActionSequence {
439 /// Tool names (lowercased at compile time).
440 sequence: Vec<String>,
441 /// `true` = ordered subsequence, `false` = unordered set.
442 ordered: bool,
443 deny_reason: String,
444 },
445
446 /// Workflow template: DAG of allowed tool transitions.
447 ///
448 /// Non-governed tools pass through (no restriction). Governed tools must
449 /// follow the DAG edges: the current tool must be a valid successor of the
450 /// most recent governed tool in history, or an entry point if no governed
451 /// tool has been called yet.
452 ///
453 /// Max 50 steps. Cycles rejected at compile time via Kahn's algorithm.
454 WorkflowTemplate {
455 /// Tool → valid successor tools.
456 adjacency: HashMap<String, Vec<String>>,
457 /// All tools appearing in the DAG.
458 governed_tools: HashSet<String>,
459 /// Tools with no predecessors (valid starting points).
460 entry_points: Vec<String>,
461 /// `true` = Deny on violation, `false` = warn only.
462 strict: bool,
463 deny_reason: String,
464 },
465}
466
467/// Pre-parsed fields extracted from a policy's `conditions` JSON.
468///
469/// Returned by [`PolicyEngine::compile_conditions`] to avoid a complex tuple return type.
470#[derive(Debug, Clone)]
471pub(crate) struct CompiledConditions {
472 pub require_approval: bool,
473 pub forbidden_parameters: Vec<String>,
474 pub required_parameters: Vec<String>,
475 pub constraints: Vec<CompiledConstraint>,
476 pub on_no_match_continue: bool,
477 pub context_conditions: Vec<CompiledContextCondition>,
478}
479
480/// A policy with all patterns pre-compiled for zero-lock evaluation.
481///
482/// Created by [`crate::PolicyEngine::compile_policies`] or [`crate::PolicyEngine::with_policies`].
483/// Stores the original [`Policy`](vellaveto_types::Policy) alongside pre-compiled matchers so that
484/// `evaluate_action` requires zero Mutex acquisitions.
485#[derive(Debug, Clone)]
486pub struct CompiledPolicy {
487 pub policy: Policy,
488 pub tool_matcher: CompiledToolMatcher,
489 pub require_approval: bool,
490 pub forbidden_parameters: Vec<String>,
491 pub required_parameters: Vec<String>,
492 pub constraints: Vec<CompiledConstraint>,
493 /// When true, return None (skip to next policy) instead of Allow when no
494 /// constraints fire. Set via `on_no_match: "continue"` in conditions JSON.
495 pub on_no_match_continue: bool,
496 /// Pre-computed "Denied by policy 'NAME'" reason string.
497 pub deny_reason: String,
498 /// Pre-computed "Approval required by policy 'NAME'" reason string.
499 pub approval_reason: String,
500 /// Pre-computed "Parameter 'P' is forbidden by policy 'NAME'" for each forbidden param.
501 pub forbidden_reasons: Vec<String>,
502 /// Pre-computed "Required parameter 'P' missing (policy 'NAME')" for each required param.
503 pub required_reasons: Vec<String>,
504 /// Pre-compiled path access control rules (from policy.path_rules).
505 pub compiled_path_rules: Option<CompiledPathRules>,
506 /// Pre-compiled network access control rules (from policy.network_rules).
507 pub compiled_network_rules: Option<CompiledNetworkRules>,
508 /// Pre-compiled IP access control rules (DNS rebinding protection).
509 pub compiled_ip_rules: Option<CompiledIpRules>,
510 /// Pre-compiled context conditions (from conditions JSON `context_conditions` key).
511 pub context_conditions: Vec<CompiledContextCondition>,
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use serde_json::json;
518
519 fn make_glob_constraint(param: &str, pattern: &str) -> CompiledConstraint {
520 let glob = globset::GlobBuilder::new(pattern)
521 .literal_separator(true)
522 .build()
523 .unwrap()
524 .compile_matcher();
525 CompiledConstraint::Glob {
526 param: param.to_string(),
527 matcher: glob,
528 pattern_str: pattern.to_string(),
529 on_match: "deny".to_string(),
530 on_missing: "skip".to_string(),
531 }
532 }
533
534 fn make_eq_constraint(param: &str, value: serde_json::Value) -> CompiledConstraint {
535 CompiledConstraint::Eq {
536 param: param.to_string(),
537 value,
538 on_match: "deny".to_string(),
539 on_missing: "deny".to_string(),
540 }
541 }
542
543 fn make_ne_constraint(param: &str, value: serde_json::Value) -> CompiledConstraint {
544 CompiledConstraint::Ne {
545 param: param.to_string(),
546 value,
547 on_match: "deny".to_string(),
548 on_missing: "skip".to_string(),
549 }
550 }
551
552 fn make_one_of_constraint(param: &str, values: Vec<serde_json::Value>) -> CompiledConstraint {
553 CompiledConstraint::OneOf {
554 param: param.to_string(),
555 values,
556 on_match: "allow".to_string(),
557 on_missing: "deny".to_string(),
558 }
559 }
560
561 fn make_none_of_constraint(param: &str, values: Vec<serde_json::Value>) -> CompiledConstraint {
562 CompiledConstraint::NoneOf {
563 param: param.to_string(),
564 values,
565 on_match: "deny".to_string(),
566 on_missing: "skip".to_string(),
567 }
568 }
569
570 // ---- CompiledConstraint accessor tests ----
571
572 #[test]
573 fn test_compiled_constraint_param_glob() {
574 let c = make_glob_constraint("file_path", "/tmp/**");
575 assert_eq!(c.param(), "file_path");
576 }
577
578 #[test]
579 fn test_compiled_constraint_param_eq() {
580 let c = make_eq_constraint("mode", json!("read"));
581 assert_eq!(c.param(), "mode");
582 }
583
584 #[test]
585 fn test_compiled_constraint_on_match_returns_correct_value() {
586 let c = make_glob_constraint("path", "*.exe");
587 assert_eq!(c.on_match(), "deny");
588 }
589
590 #[test]
591 fn test_compiled_constraint_on_missing_returns_correct_value() {
592 let c = make_glob_constraint("path", "*.exe");
593 assert_eq!(c.on_missing(), "skip");
594 }
595
596 #[test]
597 fn test_compiled_constraint_on_match_ne() {
598 let c = make_ne_constraint("level", json!(0));
599 assert_eq!(c.on_match(), "deny");
600 }
601
602 #[test]
603 fn test_compiled_constraint_on_missing_ne() {
604 let c = make_ne_constraint("level", json!(0));
605 assert_eq!(c.on_missing(), "skip");
606 }
607
608 #[test]
609 fn test_compiled_constraint_on_match_one_of() {
610 let c = make_one_of_constraint("env", vec![json!("dev"), json!("staging")]);
611 assert_eq!(c.on_match(), "allow");
612 }
613
614 #[test]
615 fn test_compiled_constraint_on_missing_one_of() {
616 let c = make_one_of_constraint("env", vec![json!("dev")]);
617 assert_eq!(c.on_missing(), "deny");
618 }
619
620 #[test]
621 fn test_compiled_constraint_param_none_of() {
622 let c = make_none_of_constraint("action", vec![json!("delete")]);
623 assert_eq!(c.param(), "action");
624 }
625
626 #[test]
627 fn test_compiled_constraint_param_all_variants() {
628 // Verify param() returns correct value for every variant
629 let domain_match = CompiledConstraint::DomainMatch {
630 param: "url".to_string(),
631 pattern: "*.example.com".to_string(),
632 on_match: "deny".to_string(),
633 on_missing: "deny".to_string(),
634 };
635 assert_eq!(domain_match.param(), "url");
636
637 let domain_not_in = CompiledConstraint::DomainNotIn {
638 param: "endpoint".to_string(),
639 patterns: vec!["example.com".to_string()],
640 on_match: "deny".to_string(),
641 on_missing: "skip".to_string(),
642 };
643 assert_eq!(domain_not_in.param(), "endpoint");
644
645 let regex_c = CompiledConstraint::Regex {
646 param: "input".to_string(),
647 regex: Regex::new("^[a-z]+$").unwrap(),
648 pattern_str: "^[a-z]+$".to_string(),
649 on_match: "allow".to_string(),
650 on_missing: "deny".to_string(),
651 };
652 assert_eq!(regex_c.param(), "input");
653
654 let not_glob = CompiledConstraint::NotGlob {
655 param: "file".to_string(),
656 matchers: vec![],
657 on_match: "deny".to_string(),
658 on_missing: "deny".to_string(),
659 };
660 assert_eq!(not_glob.param(), "file");
661 }
662
663 #[test]
664 fn test_compiled_constraint_on_match_all_variants() {
665 let domain_match = CompiledConstraint::DomainMatch {
666 param: "url".to_string(),
667 pattern: "evil.com".to_string(),
668 on_match: "require_approval".to_string(),
669 on_missing: "deny".to_string(),
670 };
671 assert_eq!(domain_match.on_match(), "require_approval");
672
673 let regex_c = CompiledConstraint::Regex {
674 param: "q".to_string(),
675 regex: Regex::new(".*").unwrap(),
676 pattern_str: ".*".to_string(),
677 on_match: "allow".to_string(),
678 on_missing: "skip".to_string(),
679 };
680 assert_eq!(regex_c.on_match(), "allow");
681 }
682
683 #[test]
684 fn test_compiled_constraint_on_missing_all_variants() {
685 let domain_not_in = CompiledConstraint::DomainNotIn {
686 param: "host".to_string(),
687 patterns: vec![],
688 on_match: "deny".to_string(),
689 on_missing: "deny".to_string(),
690 };
691 assert_eq!(domain_not_in.on_missing(), "deny");
692 }
693
694 // ---- CompiledPathRules tests ----
695
696 #[test]
697 fn test_compiled_path_rules_empty_allowed_and_blocked() {
698 let rules = CompiledPathRules {
699 allowed: vec![],
700 blocked: vec![],
701 };
702 assert!(rules.allowed.is_empty());
703 assert!(rules.blocked.is_empty());
704 }
705
706 // ---- CompiledNetworkRules tests ----
707
708 #[test]
709 fn test_compiled_network_rules_empty() {
710 let rules = CompiledNetworkRules {
711 allowed_domains: vec![],
712 blocked_domains: vec![],
713 };
714 assert!(rules.allowed_domains.is_empty());
715 assert!(rules.blocked_domains.is_empty());
716 }
717
718 // ---- CompiledIpRules tests ----
719
720 #[test]
721 fn test_compiled_ip_rules_block_private_default() {
722 let rules = CompiledIpRules {
723 block_private: false,
724 blocked_cidrs: vec![],
725 allowed_cidrs: vec![],
726 };
727 assert!(!rules.block_private);
728 assert!(rules.blocked_cidrs.is_empty());
729 }
730
731 #[test]
732 fn test_compiled_ip_rules_with_cidrs() {
733 let blocked: ipnet::IpNet = "10.0.0.0/8".parse().unwrap();
734 let allowed: ipnet::IpNet = "192.168.1.0/24".parse().unwrap();
735 let rules = CompiledIpRules {
736 block_private: true,
737 blocked_cidrs: vec![blocked],
738 allowed_cidrs: vec![allowed],
739 };
740 assert!(rules.block_private);
741 assert_eq!(rules.blocked_cidrs.len(), 1);
742 assert_eq!(rules.allowed_cidrs.len(), 1);
743 }
744
745 // ---- CompiledConditions tests ----
746
747 #[test]
748 fn test_compiled_conditions_default_state() {
749 let conds = CompiledConditions {
750 require_approval: false,
751 forbidden_parameters: vec![],
752 required_parameters: vec![],
753 constraints: vec![],
754 on_no_match_continue: false,
755 context_conditions: vec![],
756 };
757 assert!(!conds.require_approval);
758 assert!(conds.constraints.is_empty());
759 assert!(!conds.on_no_match_continue);
760 }
761
762 #[test]
763 fn test_compiled_conditions_with_approval() {
764 let conds = CompiledConditions {
765 require_approval: true,
766 forbidden_parameters: vec!["secret".to_string()],
767 required_parameters: vec!["token".to_string()],
768 constraints: vec![],
769 on_no_match_continue: false,
770 context_conditions: vec![],
771 };
772 assert!(conds.require_approval);
773 assert_eq!(conds.forbidden_parameters, vec!["secret"]);
774 assert_eq!(conds.required_parameters, vec!["token"]);
775 }
776}