Skip to main content

chio_api_protect/
evaluator.rs

1//! Request evaluator: matches routes, checks capabilities, signs receipts.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use chio_core_types::crypto::{Keypair, PublicKey};
7use chio_core_types::receipt::metadata::GuardEvidence;
8use chio_http_core::{
9    AuthMethod, CallerIdentity, ChioHttpRequest, HttpAuthority, HttpAuthorityError,
10    HttpAuthorityEvaluation, HttpAuthorityInput, HttpAuthorityPolicy, HttpMethod, HttpReceipt,
11    Verdict,
12};
13use chio_kernel::{ApprovalStore, ReceiptStore, RevocationStore, SignedExecutionNonce};
14use chio_openapi::PolicyDecision;
15use serde_json::Value;
16
17/// Backend label reported through the sidecar health endpoint. A durable store
18/// survives restart; an ephemeral one does not.
19const BACKEND_DURABLE: &str = "durable";
20const BACKEND_EPHEMERAL: &str = "ephemeral";
21
22/// Evaluated result for a single HTTP request.
23pub struct EvaluationResult {
24    pub verdict: Verdict,
25    pub receipt: HttpReceipt,
26    pub evidence: Vec<GuardEvidence>,
27    pub execution_nonce: Option<SignedExecutionNonce>,
28}
29
30/// Route information extracted from the OpenAPI spec.
31#[derive(Debug, Clone)]
32pub struct RouteEntry {
33    pub pattern: String,
34    pub method: HttpMethod,
35    pub operation_id: Option<String>,
36    pub policy: PolicyDecision,
37}
38
39/// The request evaluator holds the loaded route table and shared HTTP authority.
40pub struct RequestEvaluator {
41    routes: Vec<RouteEntry>,
42    authority: HttpAuthority,
43    receipt_backend: &'static str,
44    revocation_backend: &'static str,
45}
46
47#[derive(Clone)]
48pub(crate) struct DurableAdmissionStores {
49    pub(crate) store: Arc<dyn chio_kernel::QualifiedAdmissionProjectionStore>,
50    pub(crate) outcome_store: Arc<dyn chio_kernel::tool_outcome::QualifiedToolOutcomeStore>,
51    pub(crate) fence: chio_kernel::admission_operation::StoreMutationFence,
52}
53
54impl RequestEvaluator {
55    /// Compatibility shim for the pre-rename constructor name. Renamed to
56    /// [`RequestEvaluator::new_ephemeral`] so an embedder never gets in-memory
57    /// audit state by mistake; kept so existing callers keep compiling.
58    #[deprecated(
59        note = "renamed to `new_ephemeral`: this keeps the receipt log and revocation state in memory, lost on restart"
60    )]
61    pub fn new(routes: Vec<RouteEntry>, keypair: Keypair, policy_hash: String) -> Self {
62        Self::new_ephemeral(routes, keypair, policy_hash)
63    }
64
65    /// Compatibility shim for the pre-rename constructor name; see
66    /// [`RequestEvaluator::new_ephemeral_with_trusted_capability_issuers`].
67    #[deprecated(
68        note = "renamed to `new_ephemeral_with_trusted_capability_issuers`: this keeps the receipt log and revocation state in memory, lost on restart"
69    )]
70    pub fn new_with_trusted_capability_issuers(
71        routes: Vec<RouteEntry>,
72        keypair: Keypair,
73        policy_hash: String,
74        trusted_capability_issuers: Vec<PublicKey>,
75    ) -> Self {
76        Self::new_ephemeral_with_trusted_capability_issuers(
77            routes,
78            keypair,
79            policy_hash,
80            trusted_capability_issuers,
81        )
82    }
83
84    /// Compatibility shim for the pre-rename constructor name; see
85    /// [`RequestEvaluator::new_ephemeral_with_approval_store`].
86    #[deprecated(
87        note = "renamed to `new_ephemeral_with_approval_store`: this keeps the receipt log and revocation state in memory, lost on restart"
88    )]
89    pub fn new_with_approval_store(
90        routes: Vec<RouteEntry>,
91        keypair: Keypair,
92        policy_hash: String,
93        approval_store: Arc<dyn ApprovalStore>,
94    ) -> Self {
95        Self::new_ephemeral_with_approval_store(routes, keypair, policy_hash, approval_store)
96    }
97
98    /// Compatibility shim for the pre-rename constructor name; see
99    /// [`RequestEvaluator::new_ephemeral_with_approval_store_and_trusted_capability_issuers`].
100    #[deprecated(
101        note = "renamed to `new_ephemeral_with_approval_store_and_trusted_capability_issuers`: this keeps the receipt log and revocation state in memory, lost on restart"
102    )]
103    pub fn new_with_approval_store_and_trusted_capability_issuers(
104        routes: Vec<RouteEntry>,
105        keypair: Keypair,
106        policy_hash: String,
107        approval_store: Arc<dyn ApprovalStore>,
108        trusted_capability_issuers: Vec<PublicKey>,
109    ) -> Self {
110        Self::new_ephemeral_with_approval_store_and_trusted_capability_issuers(
111            routes,
112            keypair,
113            policy_hash,
114            approval_store,
115            trusted_capability_issuers,
116        )
117    }
118
119    /// Build an evaluator whose embedded kernel keeps its receipt log and
120    /// revocation state in memory; both are lost on restart. Ephemerality is
121    /// opted into through the constructor name so an embedder never gets
122    /// in-memory audit state by mistake. Durable-by-default construction goes
123    /// through [`RequestEvaluator::new_with_durable_stores`].
124    pub fn new_ephemeral(routes: Vec<RouteEntry>, keypair: Keypair, policy_hash: String) -> Self {
125        Self::new_ephemeral_with_trusted_capability_issuers(
126            routes,
127            keypair,
128            policy_hash,
129            Vec::new(),
130        )
131    }
132
133    /// Ephemeral evaluator with additional trusted capability issuers; see
134    /// [`RequestEvaluator::new_ephemeral`].
135    pub fn new_ephemeral_with_trusted_capability_issuers(
136        routes: Vec<RouteEntry>,
137        keypair: Keypair,
138        policy_hash: String,
139        trusted_capability_issuers: Vec<PublicKey>,
140    ) -> Self {
141        Self {
142            routes,
143            authority: HttpAuthority::new_ephemeral_with_approval_store_and_trusted_issuers(
144                keypair,
145                policy_hash,
146                Arc::new(chio_kernel::InMemoryApprovalStore::new()),
147                trusted_capability_issuers,
148            ),
149            receipt_backend: BACKEND_EPHEMERAL,
150            revocation_backend: BACKEND_EPHEMERAL,
151        }
152    }
153
154    /// Ephemeral evaluator with a caller-provided approval store; see
155    /// [`RequestEvaluator::new_ephemeral`].
156    pub fn new_ephemeral_with_approval_store(
157        routes: Vec<RouteEntry>,
158        keypair: Keypair,
159        policy_hash: String,
160        approval_store: Arc<dyn ApprovalStore>,
161    ) -> Self {
162        Self::new_ephemeral_with_approval_store_and_trusted_capability_issuers(
163            routes,
164            keypair,
165            policy_hash,
166            approval_store,
167            Vec::new(),
168        )
169    }
170
171    /// Ephemeral evaluator with a caller-provided approval store and additional
172    /// trusted capability issuers; see [`RequestEvaluator::new_ephemeral`].
173    pub fn new_ephemeral_with_approval_store_and_trusted_capability_issuers(
174        routes: Vec<RouteEntry>,
175        keypair: Keypair,
176        policy_hash: String,
177        approval_store: Arc<dyn ApprovalStore>,
178        trusted_capability_issuers: Vec<PublicKey>,
179    ) -> Self {
180        Self {
181            routes,
182            authority: HttpAuthority::new_ephemeral_with_approval_store_and_trusted_issuers(
183                keypair,
184                policy_hash,
185                approval_store,
186                trusted_capability_issuers,
187            ),
188            receipt_backend: BACKEND_EPHEMERAL,
189            revocation_backend: BACKEND_EPHEMERAL,
190        }
191    }
192
193    /// Build an evaluator whose embedded kernel is backed by durable stores when
194    /// provided. A `Some` store is attached and the kernel runs fail-closed
195    /// against it. When a store is absent (or an attached one reports ephemeral),
196    /// the kernel runs in-memory for that backend only when `allow_ephemeral` is
197    /// set: durable-by-default, an operator must explicitly opt into losing
198    /// receipts or revocation state on restart rather than getting it silently.
199    #[allow(clippy::too_many_arguments)]
200    pub fn new_with_durable_stores(
201        routes: Vec<RouteEntry>,
202        keypair: Keypair,
203        policy_hash: String,
204        approval_store: Arc<dyn ApprovalStore>,
205        trusted_capability_issuers: Vec<PublicKey>,
206        receipt_store: Option<Arc<dyn ReceiptStore>>,
207        revocation_store: Option<Arc<dyn RevocationStore>>,
208        allow_ephemeral: bool,
209    ) -> Result<Self, HttpAuthorityError> {
210        Self::new_with_durable_stores_and_admission(
211            routes,
212            keypair,
213            policy_hash,
214            approval_store,
215            trusted_capability_issuers,
216            receipt_store,
217            revocation_store,
218            None,
219            allow_ephemeral,
220        )
221    }
222
223    #[allow(clippy::too_many_arguments)]
224    pub(crate) fn new_with_durable_stores_and_admission(
225        routes: Vec<RouteEntry>,
226        keypair: Keypair,
227        policy_hash: String,
228        approval_store: Arc<dyn ApprovalStore>,
229        trusted_capability_issuers: Vec<PublicKey>,
230        receipt_store: Option<Arc<dyn ReceiptStore>>,
231        revocation_store: Option<Arc<dyn RevocationStore>>,
232        durable_admission: Option<DurableAdmissionStores>,
233        allow_ephemeral: bool,
234    ) -> Result<Self, HttpAuthorityError> {
235        let receipt_backend = if receipt_store.is_some() {
236            BACKEND_DURABLE
237        } else {
238            BACKEND_EPHEMERAL
239        };
240        // A revocation store counts as durable only if it survives a restart. An
241        // in-memory store may be attached to share its handle with the sidecar's
242        // release path in ephemeral mode; it must still report ephemeral so it
243        // neither advertises a durable backend nor falsely satisfies the kernel's
244        // revocation-durability gate.
245        let revocation_is_ephemeral = revocation_store
246            .as_ref()
247            .is_none_or(|store| store.is_ephemeral());
248        let revocation_backend = if revocation_is_ephemeral {
249            BACKEND_EPHEMERAL
250        } else {
251            BACKEND_DURABLE
252        };
253
254        // The ephemeral allowances gate on the explicit opt-in, not on whether a
255        // durable store happens to be absent. A durable revocation store keeps
256        // the gate satisfied regardless (the kernel checks the store's own
257        // durability); an absent or in-memory one requires `allow_ephemeral`, so
258        // an embedder with durable receipts can never silently re-accept a
259        // capability revoked before a restart from in-memory revocation state.
260        let mut builder = HttpAuthority::builder()
261            .approval_store(approval_store)
262            .trusted_capability_issuers(trusted_capability_issuers)
263            .allow_ephemeral_receipt_log(allow_ephemeral)
264            .allow_ephemeral_revocation_store(allow_ephemeral);
265        if let Some(store) = receipt_store {
266            builder = builder.receipt_store(store);
267        }
268        if let Some(store) = revocation_store {
269            builder = builder.revocation_store(store);
270        }
271        if let Some(durable) = durable_admission {
272            builder = builder.durable_admission_stores(
273                durable.store,
274                durable.outcome_store,
275                durable.fence,
276            );
277        }
278        let authority = builder.build(keypair, policy_hash)?;
279
280        Ok(Self {
281            routes,
282            authority,
283            receipt_backend,
284            revocation_backend,
285        })
286    }
287
288    /// Health label for the embedded kernel's receipt backend.
289    pub fn receipt_backend(&self) -> &'static str {
290        self.receipt_backend
291    }
292
293    /// Health label for the embedded kernel's revocation backend.
294    pub fn revocation_backend(&self) -> &'static str {
295        self.revocation_backend
296    }
297
298    #[cfg(test)]
299    #[must_use]
300    pub fn approval_store(&self) -> Arc<dyn ApprovalStore> {
301        self.authority.approval_store()
302    }
303
304    #[cfg(test)]
305    pub fn enable_strict_execution_nonce_for_tests(&mut self) {
306        let cfg = chio_kernel::ExecutionNonceConfig {
307            nonce_ttl_secs: 30,
308            nonce_store_capacity: 1024,
309            require_nonce: true,
310        };
311        let store = Box::new(chio_kernel::InMemoryExecutionNonceStore::from_config(&cfg));
312        if let Err(error) = self.authority.set_execution_nonce_store(cfg, store) {
313            panic!("strict execution nonce test setup failed: {error}");
314        }
315    }
316
317    /// Evaluate an incoming HTTP request against the route table.
318    pub fn evaluate(
319        &self,
320        method: HttpMethod,
321        path: &str,
322        query: &HashMap<String, String>,
323        headers: &HashMap<String, String>,
324        body_hash: Option<String>,
325        body_length: u64,
326    ) -> Result<EvaluationResult, crate::error::ProtectError> {
327        self.evaluate_with_execution_nonce(
328            method,
329            path,
330            query,
331            headers,
332            body_hash,
333            body_length,
334            None,
335        )
336    }
337
338    /// Evaluate an incoming HTTP request with an optional direct-proxy nonce.
339    #[allow(clippy::too_many_arguments)]
340    pub fn evaluate_with_execution_nonce(
341        &self,
342        method: HttpMethod,
343        path: &str,
344        query: &HashMap<String, String>,
345        headers: &HashMap<String, String>,
346        body_hash: Option<String>,
347        body_length: u64,
348        execution_nonce: Option<&SignedExecutionNonce>,
349    ) -> Result<EvaluationResult, crate::error::ProtectError> {
350        // A nonce retry is the same request; retain its signed idempotency identity.
351        let request_id = execution_nonce.map_or_else(
352            || uuid::Uuid::now_v7().to_string(),
353            |nonce| nonce.nonce.bound_to.request_id.clone(),
354        );
355        let caller = caller_identity_from_headers(headers);
356        let (route_pattern, matched_policy) = self.match_route(method, path);
357        let result = self.authority.evaluate(HttpAuthorityInput {
358            request_id,
359            method,
360            route_pattern,
361            path,
362            query,
363            caller,
364            body_hash,
365            body_length,
366            session_id: None,
367            capability_id_hint: None,
368            presented_capability: extract_presented_capability(headers, query),
369            requested_tool_server: None,
370            requested_tool_name: None,
371            requested_arguments: None,
372            execution_nonce,
373            model_metadata: None,
374            unsupported_authorization_extension: None,
375            policy: policy_mode(matched_policy),
376        })?;
377        Ok(result.into())
378    }
379
380    /// Evaluate a fully normalized sidecar request.
381    pub fn evaluate_chio_request(
382        &self,
383        request: ChioHttpRequest,
384        presented_capability: Option<&str>,
385    ) -> Result<EvaluationResult, crate::error::ProtectError> {
386        let unsupported_authorization_extension = request.unsupported_authorization_extension();
387        let ChioHttpRequest {
388            request_id,
389            method,
390            route_pattern: _client_route_pattern,
391            path,
392            query,
393            headers,
394            caller,
395            body_hash,
396            body_length,
397            session_id,
398            capability_id,
399            tool_server,
400            tool_name,
401            arguments,
402            model_metadata,
403            execution_nonce,
404            ..
405        } = request;
406        let (route_pattern, matched_policy, _) = self.match_route_with_status(method, &path);
407        let raw_capability =
408            presented_capability.or_else(|| extract_presented_capability(&headers, &query));
409        let arguments = arguments.unwrap_or(Value::Null);
410        let result = self.authority.evaluate(HttpAuthorityInput {
411            request_id,
412            method,
413            route_pattern,
414            path: &path,
415            query: &query,
416            caller,
417            body_hash,
418            body_length,
419            session_id,
420            capability_id_hint: capability_id.as_deref(),
421            presented_capability: raw_capability,
422            requested_tool_server: tool_server.as_deref(),
423            requested_tool_name: tool_name.as_deref(),
424            requested_arguments: Some(&arguments),
425            execution_nonce: execution_nonce.as_ref(),
426            model_metadata: model_metadata.as_ref(),
427            unsupported_authorization_extension,
428            policy: policy_mode(matched_policy),
429        })?;
430        Ok(result.into())
431    }
432
433    /// Match a request path against the route table.
434    /// Returns (matched_pattern, policy). Falls back to a catch-all.
435    fn match_route(&self, method: HttpMethod, path: &str) -> (String, PolicyDecision) {
436        let (pattern, policy, _) = self.match_route_with_status(method, path);
437        (pattern, policy)
438    }
439
440    fn match_route_with_status(
441        &self,
442        method: HttpMethod,
443        path: &str,
444    ) -> (String, PolicyDecision, bool) {
445        // Try exact pattern match first, then prefix match.
446        for route in &self.routes {
447            if route.method == method && path_matches_pattern(path, &route.pattern) {
448                return (route.pattern.clone(), route.policy, true);
449            }
450        }
451
452        // Fallback: use method-based default policy.
453        let pattern = path.to_string();
454        let policy = if method.is_safe() {
455            PolicyDecision::SessionAllow
456        } else {
457            PolicyDecision::DenyByDefault
458        };
459        (pattern, policy, false)
460    }
461}
462
463fn extract_presented_capability<'a>(
464    headers: &'a HashMap<String, String>,
465    query: &'a HashMap<String, String>,
466) -> Option<&'a str> {
467    header_value(headers, "x-chio-capability")
468        .or_else(|| query.get("chio_capability").map(String::as_str))
469}
470
471fn policy_mode(policy: PolicyDecision) -> HttpAuthorityPolicy {
472    match policy {
473        PolicyDecision::SessionAllow => HttpAuthorityPolicy::SessionAllow,
474        PolicyDecision::DenyByDefault => HttpAuthorityPolicy::DenyByDefault,
475    }
476}
477
478impl RequestEvaluator {
479    pub fn finalize_receipt(
480        &self,
481        decision_receipt: &HttpReceipt,
482        response_status: u16,
483    ) -> Result<HttpReceipt, crate::error::ProtectError> {
484        self.authority
485            .finalize_decision_receipt(decision_receipt, response_status)
486            .map_err(Into::into)
487    }
488}
489
490impl From<HttpAuthorityEvaluation> for EvaluationResult {
491    fn from(value: HttpAuthorityEvaluation) -> Self {
492        Self {
493            verdict: value.verdict,
494            receipt: value.receipt,
495            evidence: value.evidence,
496            execution_nonce: value.execution_nonce,
497        }
498    }
499}
500
501impl From<HttpAuthorityError> for crate::error::ProtectError {
502    fn from(value: HttpAuthorityError) -> Self {
503        match value {
504            HttpAuthorityError::CallerIdentity(message)
505            | HttpAuthorityError::ContentHash(message)
506            | HttpAuthorityError::Kernel(message) => Self::Evaluation(message),
507            HttpAuthorityError::PendingApproval {
508                approval_id,
509                kernel_receipt_id,
510            } => Self::PendingApproval {
511                approval_id,
512                kernel_receipt_id,
513            },
514            HttpAuthorityError::ReceiptSign(message) => Self::ReceiptSign(message),
515        }
516    }
517}
518
519/// Match OpenAPI-style path templates such as `/pets/{petId}`.
520fn path_matches_pattern(path: &str, pattern: &str) -> bool {
521    let mut path_segments = path.split('/');
522    let mut pattern_segments = pattern.split('/');
523
524    loop {
525        match (path_segments.next(), pattern_segments.next()) {
526            (Some(path_segment), Some(pattern_segment))
527                if path_segment_matches_pattern(path_segment, pattern_segment) => {}
528            (None, None) => return true,
529            _ => return false,
530        }
531    }
532}
533
534fn path_segment_matches_pattern(path_segment: &str, pattern_segment: &str) -> bool {
535    pattern_segment.starts_with('{') && pattern_segment.ends_with('}')
536        || path_segment == pattern_segment
537}
538
539/// Extract caller identity from HTTP headers.
540pub(crate) fn caller_identity_from_headers(headers: &HashMap<String, String>) -> CallerIdentity {
541    // Authorization headers become stable hashed caller identities.
542    if let Some(auth) = header_value(headers, "authorization") {
543        if let Some(token) = auth.strip_prefix("Bearer ") {
544            if credential_value_is_well_formed(token) {
545                let token_hash = chio_core_types::sha256_hex(token.as_bytes());
546                return CallerIdentity {
547                    subject: format!("bearer:{}", &token_hash[..16]),
548                    auth_method: AuthMethod::Bearer { token_hash },
549                    verified: false,
550                    tenant: None,
551                    agent_id: None,
552                };
553            }
554        }
555    }
556
557    // API keys follow the same non-secret identity derivation.
558    if let Some(key_value) = header_value(headers, "x-api-key") {
559        if credential_value_is_well_formed(key_value) {
560            let key_hash = chio_core_types::sha256_hex(key_value.as_bytes());
561            return CallerIdentity {
562                subject: format!("apikey:{}", &key_hash[..16]),
563                auth_method: AuthMethod::ApiKey {
564                    key_name: "x-api-key".to_string(),
565                    key_hash,
566                },
567                verified: false,
568                tenant: None,
569                agent_id: None,
570            };
571        }
572    }
573
574    CallerIdentity::anonymous()
575}
576
577pub(crate) fn header_value<'a>(
578    headers: &'a HashMap<String, String>,
579    name: &str,
580) -> Option<&'a str> {
581    headers
582        .iter()
583        .find(|(key, _)| key.eq_ignore_ascii_case(name))
584        .map(|(_, value)| value.as_str())
585}
586
587fn credential_value_is_well_formed(value: &str) -> bool {
588    !value.trim().is_empty()
589        && value.trim() == value
590        && !value.chars().any(|character| character.is_control())
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use chio_core_types::capability::{
597        governance::ProvenanceEvidenceClass,
598        scope::{ChioScope, Constraint, ModelMetadata, ModelSafetyTier, Operation, ToolGrant},
599        token::{CapabilityToken, CapabilityTokenBody},
600    };
601    use chio_http_core::{
602        http_status_scope, CHIO_DECISION_RECEIPT_ID_KEY, CHIO_HTTP_STATUS_SCOPE_DECISION,
603        CHIO_HTTP_STATUS_SCOPE_FINAL,
604    };
605
606    use chio_test_support::prelude::*;
607
608    #[test]
609    fn durable_evaluator_reports_durable_backends() -> Result<(), Box<dyn std::error::Error>> {
610        let dir = tempfile::tempdir()?;
611        #[cfg(unix)]
612        {
613            use std::os::unix::fs::PermissionsExt;
614            std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?;
615        }
616        let receipt_store: Arc<dyn chio_kernel::ReceiptStore> = Arc::new(
617            chio_store_sqlite::SqliteReceiptStore::open(dir.path().join("receipts.db"))?,
618        );
619        let revocation_store: Arc<dyn chio_kernel::RevocationStore> = Arc::new(
620            chio_store_sqlite::SqliteRevocationStore::open(dir.path().join("revocations.db"))?,
621        );
622        let authority_database = dir.path().join("authority.db");
623        let authority_locks = dir.path().join("authority-locks");
624        let mut lock_root_builder = std::fs::DirBuilder::new();
625        #[cfg(unix)]
626        {
627            use std::os::unix::fs::DirBuilderExt;
628            lock_root_builder.mode(0o700);
629        }
630        lock_root_builder.create(&authority_locks)?;
631        chio_store_sqlite::SqliteAuthorityStore::provision(&authority_database, &authority_locks)?;
632        let authority = chio_store_sqlite::SqliteAuthorityStore::open_serving(
633            &authority_database,
634            &authority_locks,
635        )?;
636        let evaluator = RequestEvaluator::new_with_durable_stores_and_admission(
637            Vec::new(),
638            Keypair::generate(),
639            chio_core_types::sha256_hex(b"test-policy"),
640            Arc::new(chio_kernel::InMemoryApprovalStore::new()),
641            Vec::new(),
642            Some(receipt_store),
643            Some(revocation_store),
644            Some(DurableAdmissionStores {
645                store: Arc::new(authority.admission_operation_store()),
646                outcome_store: Arc::new(authority.tool_outcome_store()),
647                fence: authority.mutation_fence(),
648            }),
649            false,
650        )?;
651        assert_eq!(evaluator.receipt_backend(), "durable");
652        assert_eq!(evaluator.revocation_backend(), "durable");
653        let evaluated = evaluator.evaluate(
654            HttpMethod::Get,
655            "/pets",
656            &HashMap::new(),
657            &HashMap::new(),
658            None,
659            0,
660        )?;
661        assert!(evaluated.verdict.is_allowed());
662        Ok(())
663    }
664
665    /// Durable-by-default for revocation: an evaluator with durable receipts but
666    /// no durable revocation store must fail closed on a mediated request unless
667    /// the operator explicitly opts into ephemeral operation. Without the opt-in
668    /// the kernel refuses pre-dispatch rather than serving a capability whose
669    /// revocation state is lost on restart; with it, the same request serves,
670    /// matching the explicit-ephemeral path.
671    #[test]
672    fn durable_evaluator_requires_explicit_opt_in_for_ephemeral_revocation(
673    ) -> Result<(), Box<dyn std::error::Error>> {
674        let dir = tempfile::tempdir()?;
675        let keypair = Keypair::generate();
676        let mut headers = HashMap::new();
677        headers.insert(
678            "X-Chio-Capability".to_string(),
679            signed_capability_token_json(&keypair, "cap-revocation-gate"),
680        );
681
682        let build = |file: &str,
683                     allow_ephemeral: bool|
684         -> Result<RequestEvaluator, Box<dyn std::error::Error>> {
685            let receipt_store: Arc<dyn chio_kernel::ReceiptStore> = Arc::new(
686                chio_store_sqlite::SqliteReceiptStore::open(dir.path().join(file))?,
687            );
688            Ok(RequestEvaluator::new_with_durable_stores(
689                vec![],
690                keypair.clone(),
691                "test-policy".to_string(),
692                Arc::new(chio_kernel::InMemoryApprovalStore::new()),
693                Vec::new(),
694                Some(receipt_store),
695                None,
696                allow_ephemeral,
697            )?)
698        };
699
700        // No opt-in: durable receipts plus in-memory revocation fails closed.
701        // The kernel refuses pre-dispatch, surfaced as an evaluation error.
702        let denied = build("gated.db", false)?.evaluate(
703            HttpMethod::Post,
704            "/pets",
705            &HashMap::new(),
706            &headers,
707            None,
708            0,
709        );
710        let error = match denied {
711            Ok(_) => panic!("in-memory revocation without an opt-in must fail closed"),
712            Err(error) => error,
713        };
714        assert!(
715            error.to_string().contains("revocation"),
716            "the failure must name the missing revocation durability, got: {error}"
717        );
718
719        // Explicit opt-in: the same configuration serves the request.
720        let allowed = build("opted.db", true)?.evaluate(
721            HttpMethod::Post,
722            "/pets",
723            &HashMap::new(),
724            &headers,
725            None,
726            0,
727        )?;
728        assert!(
729            allowed.verdict.is_allowed(),
730            "an explicit ephemeral opt-in restores in-memory revocation serving"
731        );
732        Ok(())
733    }
734
735    /// The pre-rename constructor names remain source-compatible for external
736    /// embedders, delegating to the explicit-ephemeral path. Without the shims
737    /// this test would not compile.
738    #[test]
739    #[allow(deprecated)]
740    fn deprecated_constructor_shims_delegate_to_ephemeral() {
741        let keypair = Keypair::generate();
742
743        let evaluator = RequestEvaluator::new(vec![], keypair.clone(), "test-policy".to_string());
744        assert_eq!(evaluator.receipt_backend(), "ephemeral");
745        assert_eq!(evaluator.revocation_backend(), "ephemeral");
746
747        let evaluator = RequestEvaluator::new_with_trusted_capability_issuers(
748            vec![],
749            keypair.clone(),
750            "test-policy".to_string(),
751            vec![],
752        );
753        assert_eq!(evaluator.receipt_backend(), "ephemeral");
754
755        let evaluator = RequestEvaluator::new_with_approval_store(
756            vec![],
757            keypair.clone(),
758            "test-policy".to_string(),
759            Arc::new(chio_kernel::InMemoryApprovalStore::new()),
760        );
761        assert_eq!(evaluator.receipt_backend(), "ephemeral");
762
763        let evaluator = RequestEvaluator::new_with_approval_store_and_trusted_capability_issuers(
764            vec![],
765            keypair,
766            "test-policy".to_string(),
767            Arc::new(chio_kernel::InMemoryApprovalStore::new()),
768            vec![],
769        );
770        assert_eq!(evaluator.revocation_backend(), "ephemeral");
771    }
772
773    #[test]
774    fn evaluator_without_durable_stores_reports_ephemeral_backends(
775    ) -> Result<(), Box<dyn std::error::Error>> {
776        let evaluator = RequestEvaluator::new_with_durable_stores(
777            Vec::new(),
778            Keypair::generate(),
779            "test-policy".to_string(),
780            Arc::new(chio_kernel::InMemoryApprovalStore::new()),
781            Vec::new(),
782            None,
783            None,
784            true,
785        )?;
786        assert_eq!(evaluator.receipt_backend(), "ephemeral");
787        assert_eq!(evaluator.revocation_backend(), "ephemeral");
788        Ok(())
789    }
790
791    fn signed_capability_token_json(issuer: &Keypair, id: &str) -> String {
792        signed_capability_token_json_with_scope(
793            issuer,
794            id,
795            ChioScope {
796                grants: vec![chio_http_core::http_authority_tool_grant()],
797                ..ChioScope::default()
798            },
799        )
800    }
801
802    fn signed_capability_token_json_with_scope(
803        issuer: &Keypair,
804        id: &str,
805        scope: ChioScope,
806    ) -> String {
807        let now = chrono::Utc::now().timestamp() as u64;
808        let token = CapabilityToken::sign(
809            CapabilityTokenBody {
810                id: id.to_string(),
811                issuer: issuer.public_key(),
812                subject: issuer.public_key(),
813                scope,
814                issued_at: now.saturating_sub(60),
815                expires_at: now + 3600,
816                delegation_chain: Vec::new(),
817                aggregate_invocation_budget: None,
818            },
819            issuer,
820        )
821        .test_unwrap();
822        serde_json::to_string(&token).test_unwrap()
823    }
824
825    #[test]
826    fn path_matching() {
827        assert!(path_matches_pattern("/pets/42", "/pets/{petId}"));
828        assert!(path_matches_pattern("/pets", "/pets"));
829        assert!(!path_matches_pattern("/pets/42/toys", "/pets/{petId}"));
830        assert!(!path_matches_pattern("/dogs/42", "/pets/{petId}"));
831    }
832
833    #[test]
834    fn extract_bearer_caller() {
835        let mut headers = HashMap::new();
836        headers.insert("Authorization".to_string(), "Bearer mytoken123".to_string());
837        let caller = caller_identity_from_headers(&headers);
838        assert!(caller.subject.starts_with("bearer:"));
839        assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
840    }
841
842    #[test]
843    fn extract_bearer_caller_accepts_case_insensitive_authorization_header() {
844        let mut headers = HashMap::new();
845        headers.insert("AUTHORIZATION".to_string(), "Bearer mytoken123".to_string());
846        let caller = caller_identity_from_headers(&headers);
847        assert!(caller.subject.starts_with("bearer:"));
848        assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
849    }
850
851    #[test]
852    fn extract_caller_ignores_blank_bearer_token() {
853        let mut headers = HashMap::new();
854        headers.insert("authorization".to_string(), "Bearer ".to_string());
855
856        let caller = caller_identity_from_headers(&headers);
857
858        assert!(matches!(caller.auth_method, AuthMethod::Anonymous));
859        assert_eq!(caller.subject, "anonymous");
860    }
861
862    #[test]
863    fn extract_anonymous_caller() {
864        let headers = HashMap::new();
865        let caller = caller_identity_from_headers(&headers);
866        assert_eq!(caller.subject, "anonymous");
867    }
868
869    #[test]
870    fn evaluate_get_allowed() {
871        let keypair = Keypair::generate();
872        let routes = vec![RouteEntry {
873            pattern: "/pets".to_string(),
874            method: HttpMethod::Get,
875            operation_id: Some("listPets".to_string()),
876            policy: PolicyDecision::SessionAllow,
877        }];
878        let evaluator =
879            RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
880
881        let result = evaluator
882            .evaluate(
883                HttpMethod::Get,
884                "/pets",
885                &HashMap::new(),
886                &HashMap::new(),
887                None,
888                0,
889            )
890            .test_unwrap();
891        assert!(result.verdict.is_allowed());
892        assert!(result.receipt.verify_signature().test_unwrap());
893        assert_eq!(
894            http_status_scope(result.receipt.metadata.as_ref()),
895            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
896        );
897    }
898
899    #[test]
900    fn evaluate_chio_request_signed_denies_unsupported_approval_sets() {
901        let keypair = Keypair::generate();
902        let routes = vec![RouteEntry {
903            pattern: "/pets".to_string(),
904            method: HttpMethod::Get,
905            operation_id: Some("listPets".to_string()),
906            policy: PolicyDecision::SessionAllow,
907        }];
908        let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
909        let mut request = ChioHttpRequest::new(
910            "req-unsupported-approvals".to_string(),
911            HttpMethod::Get,
912            "/pets".to_string(),
913            "/pets".to_string(),
914            CallerIdentity::anonymous(),
915        );
916        request.approval_tokens = Some(serde_json::json!([
917            { "id": "approval-a" },
918            { "id": "approval-b" }
919        ]));
920
921        let result = evaluator.evaluate_chio_request(request, None).test_unwrap();
922
923        assert!(result.verdict.is_denied());
924        assert!(result.receipt.verify_signature().test_unwrap());
925        assert!(result.receipt.evidence[0]
926            .details
927            .as_deref()
928            .is_some_and(|details| details.contains(
929                "HTTP authority projection does not support authorization field approval_tokens"
930            )));
931    }
932
933    #[test]
934    fn evaluate_chio_request_denies_spoofed_tool_identity_on_http_route() {
935        let keypair = Keypair::generate();
936        let routes = vec![RouteEntry {
937            pattern: "/pets".to_string(),
938            method: HttpMethod::Post,
939            operation_id: Some("createPet".to_string()),
940            policy: PolicyDecision::DenyByDefault,
941        }];
942        let evaluator =
943            RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
944        let capability = signed_capability_token_json_with_scope(
945            &keypair,
946            "cap-math-only",
947            ChioScope {
948                grants: vec![ToolGrant {
949                    server_id: "math".to_string(),
950                    tool_name: "double".to_string(),
951                    operations: vec![Operation::Invoke],
952                    constraints: Vec::new(),
953                    max_invocations: None,
954                    max_cost_per_invocation: None,
955                    max_total_cost: None,
956                    dpop_required: None,
957                }],
958                ..ChioScope::default()
959            },
960        );
961
962        let mut request = ChioHttpRequest::new(
963            "req-sidecar-spoofed-tool".to_string(),
964            HttpMethod::Post,
965            "/pets".to_string(),
966            "/pets".to_string(),
967            CallerIdentity::anonymous(),
968        );
969        request.tool_server = Some("math".to_string());
970        request.tool_name = Some("double".to_string());
971        request.arguments = Some(serde_json::json!({ "value": 1 }));
972        request.body_hash = Some("pet-body".to_string());
973        request.body_length = 8;
974
975        let result = evaluator
976            .evaluate_chio_request(request, Some(&capability))
977            .test_unwrap();
978
979        assert!(result.verdict.is_denied());
980        assert!(result.receipt.capability_id.is_none());
981        assert!(result.receipt.evidence[0]
982            .details
983            .as_deref()
984            .is_some_and(|details| {
985                details.contains("capability does not authorize tool authorize_http_request")
986            }));
987    }
988
989    #[test]
990    fn evaluate_denies_get_reserved_tools_path_without_capability() {
991        let keypair = Keypair::generate();
992        let evaluator = RequestEvaluator::new_ephemeral(vec![], keypair, "test-policy".to_string());
993
994        let result = evaluator
995            .evaluate(
996                HttpMethod::Get,
997                "/chio/tools/billing/read",
998                &HashMap::new(),
999                &HashMap::new(),
1000                None,
1001                0,
1002            )
1003            .test_unwrap();
1004
1005        assert!(result.verdict.is_denied());
1006        assert!(result.receipt.capability_id.is_none());
1007        assert_eq!(
1008            result.receipt.evidence[0].details.as_deref(),
1009            Some("side-effect route requires a valid capability token")
1010        );
1011    }
1012
1013    #[test]
1014    fn evaluate_chio_request_allows_reserved_tools_path_context() {
1015        let keypair = Keypair::generate();
1016        let evaluator =
1017            RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1018        let capability = signed_capability_token_json_with_scope(
1019            &keypair,
1020            "cap-matrix-read",
1021            ChioScope {
1022                grants: vec![ToolGrant {
1023                    server_id: "matrix".to_string(),
1024                    tool_name: "files.read".to_string(),
1025                    operations: vec![Operation::Invoke],
1026                    constraints: Vec::new(),
1027                    max_invocations: None,
1028                    max_cost_per_invocation: None,
1029                    max_total_cost: None,
1030                    dpop_required: None,
1031                }],
1032                ..ChioScope::default()
1033            },
1034        );
1035
1036        let mut request = ChioHttpRequest::new(
1037            "req-sidecar-matrix-tool".to_string(),
1038            HttpMethod::Post,
1039            "/chio/tools/matrix/files.read".to_string(),
1040            "/chio/tools/matrix/files.read".to_string(),
1041            CallerIdentity::anonymous(),
1042        );
1043        request.tool_server = Some("matrix".to_string());
1044        request.tool_name = Some("files.read".to_string());
1045        request.arguments = Some(serde_json::json!({ "path": "/tmp/a" }));
1046        request.body_hash = Some("tool-body".to_string());
1047        request.body_length = 8;
1048
1049        let result = evaluator
1050            .evaluate_chio_request(request, Some(&capability))
1051            .test_unwrap();
1052
1053        assert!(result.verdict.is_allowed());
1054        assert_eq!(
1055            result.receipt.capability_id.as_deref(),
1056            Some("cap-matrix-read")
1057        );
1058    }
1059
1060    #[test]
1061    fn evaluate_chio_request_denies_unmatched_http_path_with_spoofed_synthetic_pattern() {
1062        let keypair = Keypair::generate();
1063        let evaluator =
1064            RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1065        let capability = signed_capability_token_json_with_scope(
1066            &keypair,
1067            "cap-matrix-admin-delete",
1068            ChioScope {
1069                grants: vec![ToolGrant {
1070                    server_id: "matrix".to_string(),
1071                    tool_name: "admin.delete".to_string(),
1072                    operations: vec![Operation::Invoke],
1073                    constraints: Vec::new(),
1074                    max_invocations: None,
1075                    max_cost_per_invocation: None,
1076                    max_total_cost: None,
1077                    dpop_required: None,
1078                }],
1079                ..ChioScope::default()
1080            },
1081        );
1082
1083        let mut request = ChioHttpRequest::new(
1084            "req-unmatched-spoofed-synthetic-pattern".to_string(),
1085            HttpMethod::Post,
1086            "matrix:admin.delete".to_string(),
1087            "/admin/delete".to_string(),
1088            CallerIdentity::anonymous(),
1089        );
1090        request.tool_server = Some("matrix".to_string());
1091        request.tool_name = Some("admin.delete".to_string());
1092        request.arguments = Some(serde_json::json!({ "path": "/tmp/a" }));
1093        request.body_hash = Some("tool-body".to_string());
1094        request.body_length = 8;
1095
1096        let result = evaluator
1097            .evaluate_chio_request(request, Some(&capability))
1098            .test_unwrap();
1099
1100        assert!(result.verdict.is_denied());
1101        assert_eq!(result.receipt.route_pattern, "/admin/delete");
1102        assert!(result.receipt.capability_id.is_none());
1103        assert!(result.receipt.evidence[0]
1104            .details
1105            .as_deref()
1106            .is_some_and(|details| {
1107                details.contains("capability does not authorize tool authorize_http_request")
1108            }));
1109    }
1110
1111    #[test]
1112    fn evaluate_chio_request_denies_capability_for_different_tool_identity() {
1113        let keypair = Keypair::generate();
1114        let evaluator =
1115            RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1116        let capability = signed_capability_token_json_with_scope(
1117            &keypair,
1118            "cap-tool-scope",
1119            ChioScope {
1120                grants: vec![ToolGrant {
1121                    server_id: "math".to_string(),
1122                    tool_name: "double".to_string(),
1123                    operations: vec![Operation::Invoke],
1124                    constraints: Vec::new(),
1125                    max_invocations: None,
1126                    max_cost_per_invocation: None,
1127                    max_total_cost: None,
1128                    dpop_required: None,
1129                }],
1130                ..ChioScope::default()
1131            },
1132        );
1133
1134        let mut request = ChioHttpRequest::new(
1135            "req-sidecar-tool-mismatch".to_string(),
1136            HttpMethod::Post,
1137            "/chio/tools/math/increment".to_string(),
1138            "/chio/tools/math/increment".to_string(),
1139            CallerIdentity::anonymous(),
1140        );
1141        request.tool_server = Some("math".to_string());
1142        request.tool_name = Some("increment".to_string());
1143        request.arguments = Some(serde_json::json!({ "value": 1 }));
1144        request.body_hash = Some("tool-body".to_string());
1145        request.body_length = 1;
1146
1147        let result = evaluator
1148            .evaluate_chio_request(request, Some(&capability))
1149            .test_unwrap();
1150
1151        assert!(result.verdict.is_denied());
1152        assert_eq!(
1153            result.receipt.evidence[0].details.as_deref(),
1154            Some("capability does not authorize tool increment on server math")
1155        );
1156    }
1157
1158    #[test]
1159    fn evaluate_chio_request_allows_model_constrained_capability_when_metadata_matches() {
1160        let keypair = Keypair::generate();
1161        let evaluator =
1162            RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1163        let capability = signed_capability_token_json_with_scope(
1164            &keypair,
1165            "cap-model-scope",
1166            ChioScope {
1167                grants: vec![ToolGrant {
1168                    server_id: "math".to_string(),
1169                    tool_name: "double".to_string(),
1170                    operations: vec![Operation::Invoke],
1171                    constraints: vec![Constraint::ModelConstraint {
1172                        allowed_model_ids: vec!["gpt-5".to_string()],
1173                        min_safety_tier: Some(ModelSafetyTier::Standard),
1174                    }],
1175                    max_invocations: None,
1176                    max_cost_per_invocation: None,
1177                    max_total_cost: None,
1178                    dpop_required: None,
1179                }],
1180                ..ChioScope::default()
1181            },
1182        );
1183
1184        let mut request = ChioHttpRequest::new(
1185            "req-model-scope".to_string(),
1186            HttpMethod::Post,
1187            "/chio/tools/math/double".to_string(),
1188            "/chio/tools/math/double".to_string(),
1189            CallerIdentity::anonymous(),
1190        );
1191        request.tool_server = Some("math".to_string());
1192        request.tool_name = Some("double".to_string());
1193        request.arguments = Some(serde_json::json!({ "value": 2 }));
1194        request.model_metadata = Some(ModelMetadata {
1195            model_id: "gpt-5".to_string(),
1196            safety_tier: Some(ModelSafetyTier::Standard),
1197            provider: Some("openai".to_string()),
1198            provenance_class: ProvenanceEvidenceClass::Asserted,
1199        });
1200        request.body_hash = Some("tool-body".to_string());
1201        request.body_length = 1;
1202
1203        let result = evaluator
1204            .evaluate_chio_request(request, Some(&capability))
1205            .test_unwrap();
1206
1207        assert!(result.verdict.is_allowed());
1208        assert_eq!(
1209            result.receipt.capability_id.as_deref(),
1210            Some("cap-model-scope")
1211        );
1212    }
1213
1214    #[test]
1215    fn evaluate_chio_request_allows_capability_from_configured_external_issuer() {
1216        let signer = Keypair::generate();
1217        let external_issuer = Keypair::generate();
1218        let evaluator = RequestEvaluator::new_ephemeral_with_trusted_capability_issuers(
1219            vec![],
1220            signer,
1221            "test-policy".to_string(),
1222            vec![external_issuer.public_key()],
1223        );
1224        let capability = signed_capability_token_json(&external_issuer, "cap-external");
1225
1226        let mut request = ChioHttpRequest::new(
1227            "req-external-issuer".to_string(),
1228            HttpMethod::Post,
1229            "/pets".to_string(),
1230            "/pets".to_string(),
1231            CallerIdentity::anonymous(),
1232        );
1233        request.body_hash = Some("body".to_string());
1234        request.body_length = 1;
1235
1236        let result = evaluator
1237            .evaluate_chio_request(request, Some(&capability))
1238            .test_unwrap();
1239
1240        assert!(result.verdict.is_allowed());
1241        assert_eq!(
1242            result.receipt.capability_id.as_deref(),
1243            Some("cap-external")
1244        );
1245    }
1246
1247    #[test]
1248    fn evaluate_post_denied_without_capability() {
1249        let keypair = Keypair::generate();
1250        let routes = vec![RouteEntry {
1251            pattern: "/pets".to_string(),
1252            method: HttpMethod::Post,
1253            operation_id: Some("createPet".to_string()),
1254            policy: PolicyDecision::DenyByDefault,
1255        }];
1256        let evaluator =
1257            RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
1258
1259        let result = evaluator
1260            .evaluate(
1261                HttpMethod::Post,
1262                "/pets",
1263                &HashMap::new(),
1264                &HashMap::new(),
1265                None,
1266                0,
1267            )
1268            .test_unwrap();
1269        assert!(result.verdict.is_denied());
1270        assert_eq!(result.receipt.response_status, 403);
1271        assert!(result.receipt.verify_signature().test_unwrap());
1272        assert_eq!(
1273            http_status_scope(result.receipt.metadata.as_ref()),
1274            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
1275        );
1276    }
1277
1278    #[test]
1279    fn evaluate_post_allowed_with_capability() {
1280        let keypair = Keypair::generate();
1281        let routes = vec![RouteEntry {
1282            pattern: "/pets".to_string(),
1283            method: HttpMethod::Post,
1284            operation_id: Some("createPet".to_string()),
1285            policy: PolicyDecision::DenyByDefault,
1286        }];
1287        let evaluator =
1288            RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
1289
1290        let mut headers = HashMap::new();
1291        headers.insert(
1292            "X-Chio-Capability".to_string(),
1293            signed_capability_token_json(&keypair, "cap-123"),
1294        );
1295
1296        let result = evaluator
1297            .evaluate(
1298                HttpMethod::Post,
1299                "/pets",
1300                &HashMap::new(),
1301                &headers,
1302                None,
1303                0,
1304            )
1305            .test_unwrap();
1306        assert!(result.verdict.is_allowed());
1307        assert_eq!(result.receipt.capability_id.as_deref(), Some("cap-123"));
1308        assert_eq!(
1309            http_status_scope(result.receipt.metadata.as_ref()),
1310            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
1311        );
1312    }
1313
1314    #[test]
1315    fn evaluate_post_accepts_case_insensitive_capability_header() {
1316        let keypair = Keypair::generate();
1317        let routes = vec![RouteEntry {
1318            pattern: "/pets".to_string(),
1319            method: HttpMethod::Post,
1320            operation_id: Some("createPet".to_string()),
1321            policy: PolicyDecision::DenyByDefault,
1322        }];
1323        let evaluator =
1324            RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
1325
1326        let mut headers = HashMap::new();
1327        headers.insert(
1328            "X-CHIO-CAPABILITY".to_string(),
1329            signed_capability_token_json(&keypair, "cap-uppercase"),
1330        );
1331
1332        let result = evaluator
1333            .evaluate(
1334                HttpMethod::Post,
1335                "/pets",
1336                &HashMap::new(),
1337                &headers,
1338                None,
1339                0,
1340            )
1341            .test_unwrap();
1342        assert!(result.verdict.is_allowed());
1343        assert_eq!(
1344            result.receipt.capability_id.as_deref(),
1345            Some("cap-uppercase")
1346        );
1347    }
1348
1349    #[test]
1350    fn finalize_receipt_rebinds_status_and_links_decision_receipt() {
1351        let keypair = Keypair::generate();
1352        let routes = vec![RouteEntry {
1353            pattern: "/pets".to_string(),
1354            method: HttpMethod::Get,
1355            operation_id: Some("listPets".to_string()),
1356            policy: PolicyDecision::SessionAllow,
1357        }];
1358        let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1359
1360        let decision = evaluator
1361            .evaluate(
1362                HttpMethod::Get,
1363                "/pets",
1364                &HashMap::new(),
1365                &HashMap::new(),
1366                None,
1367                0,
1368            )
1369            .test_unwrap()
1370            .receipt;
1371        let final_receipt = evaluator.finalize_receipt(&decision, 204).test_unwrap();
1372
1373        assert_ne!(final_receipt.id, decision.id);
1374        assert_eq!(final_receipt.response_status, 204);
1375        assert_eq!(
1376            http_status_scope(final_receipt.metadata.as_ref()),
1377            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
1378        );
1379        assert_eq!(
1380            final_receipt
1381                .metadata
1382                .as_ref()
1383                .and_then(|meta| meta.get(CHIO_DECISION_RECEIPT_ID_KEY))
1384                .and_then(|value| value.as_str()),
1385            Some(decision.id.as_str())
1386        );
1387        assert!(final_receipt.verify_signature().test_unwrap());
1388    }
1389
1390    #[test]
1391    fn path_matching_trailing_slash_mismatch() {
1392        // Trailing slash should NOT match if pattern has no trailing slash
1393        assert!(!path_matches_pattern("/pets/", "/pets"));
1394        assert!(!path_matches_pattern("/pets", "/pets/"));
1395    }
1396
1397    #[test]
1398    fn path_matching_double_slashes() {
1399        // Double slashes produce empty segments, should not match normal paths
1400        assert!(!path_matches_pattern("//pets", "/pets"));
1401    }
1402
1403    #[test]
1404    fn path_matching_case_sensitivity() {
1405        // Path matching should be case-sensitive
1406        assert!(!path_matches_pattern("/Pets", "/pets"));
1407        assert!(path_matches_pattern("/Pets", "/Pets"));
1408    }
1409
1410    #[test]
1411    fn path_matching_multiple_params() {
1412        assert!(path_matches_pattern(
1413            "/orgs/123/members/456",
1414            "/orgs/{orgId}/members/{memberId}"
1415        ));
1416        assert!(!path_matches_pattern(
1417            "/orgs/123/members",
1418            "/orgs/{orgId}/members/{memberId}"
1419        ));
1420    }
1421
1422    #[test]
1423    fn path_matching_root() {
1424        assert!(path_matches_pattern("/", "/"));
1425        assert!(!path_matches_pattern("/pets", "/"));
1426    }
1427
1428    #[test]
1429    fn extract_api_key_caller() {
1430        let mut headers = HashMap::new();
1431        headers.insert("X-API-Key".to_string(), "my-api-key-value".to_string());
1432        let caller = caller_identity_from_headers(&headers);
1433        assert!(caller.subject.starts_with("apikey:"));
1434        assert!(matches!(caller.auth_method, AuthMethod::ApiKey { .. }));
1435    }
1436
1437    #[test]
1438    fn evaluate_with_body_hash() {
1439        let keypair = Keypair::generate();
1440        let routes = vec![RouteEntry {
1441            pattern: "/data".to_string(),
1442            method: HttpMethod::Get,
1443            operation_id: Some("getData".to_string()),
1444            policy: PolicyDecision::SessionAllow,
1445        }];
1446        let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1447
1448        let result = evaluator
1449            .evaluate(
1450                HttpMethod::Get,
1451                "/data",
1452                &HashMap::new(),
1453                &HashMap::new(),
1454                Some("bodyhash123".to_string()),
1455                1024,
1456            )
1457            .test_unwrap();
1458        assert!(result.verdict.is_allowed());
1459        assert!(result.receipt.verify_signature().test_unwrap());
1460    }
1461
1462    #[test]
1463    fn fallback_policy_for_unmatched_route() {
1464        let keypair = Keypair::generate();
1465        let evaluator = RequestEvaluator::new_ephemeral(vec![], keypair, "test-policy".to_string());
1466
1467        // GET to unknown route should still be allowed (safe method)
1468        let result = evaluator
1469            .evaluate(
1470                HttpMethod::Get,
1471                "/unknown",
1472                &HashMap::new(),
1473                &HashMap::new(),
1474                None,
1475                0,
1476            )
1477            .test_unwrap();
1478        assert!(result.verdict.is_allowed());
1479
1480        // DELETE to unknown route should be denied (side-effect method)
1481        let result = evaluator
1482            .evaluate(
1483                HttpMethod::Delete,
1484                "/unknown",
1485                &HashMap::new(),
1486                &HashMap::new(),
1487                None,
1488                0,
1489            )
1490            .test_unwrap();
1491        assert!(result.verdict.is_denied());
1492    }
1493
1494    #[test]
1495    fn evaluate_invalid_capability_denied_fail_closed() {
1496        let keypair = Keypair::generate();
1497        let routes = vec![RouteEntry {
1498            pattern: "/pets".to_string(),
1499            method: HttpMethod::Post,
1500            operation_id: Some("createPet".to_string()),
1501            policy: PolicyDecision::DenyByDefault,
1502        }];
1503        let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1504        let mut headers = HashMap::new();
1505        headers.insert("X-Chio-Capability".to_string(), "not-json".to_string());
1506
1507        let result = evaluator
1508            .evaluate(
1509                HttpMethod::Post,
1510                "/pets",
1511                &HashMap::new(),
1512                &headers,
1513                None,
1514                0,
1515            )
1516            .test_unwrap();
1517
1518        assert!(result.verdict.is_denied());
1519        assert!(result.receipt.capability_id.as_deref().is_none());
1520    }
1521
1522    #[test]
1523    fn evaluate_query_parameters_affect_content_hash() {
1524        let keypair = Keypair::generate();
1525        let routes = vec![RouteEntry {
1526            pattern: "/search".to_string(),
1527            method: HttpMethod::Get,
1528            operation_id: Some("search".to_string()),
1529            policy: PolicyDecision::SessionAllow,
1530        }];
1531        let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1532        let mut query_a = HashMap::new();
1533        query_a.insert("q".to_string(), "cats".to_string());
1534        let mut query_b = HashMap::new();
1535        query_b.insert("q".to_string(), "dogs".to_string());
1536
1537        let result_a = evaluator
1538            .evaluate(
1539                HttpMethod::Get,
1540                "/search",
1541                &query_a,
1542                &HashMap::new(),
1543                None,
1544                0,
1545            )
1546            .test_unwrap();
1547        let result_b = evaluator
1548            .evaluate(
1549                HttpMethod::Get,
1550                "/search",
1551                &query_b,
1552                &HashMap::new(),
1553                None,
1554                0,
1555            )
1556            .test_unwrap();
1557
1558        assert_ne!(result_a.receipt.content_hash, result_b.receipt.content_hash);
1559    }
1560}